Merge changes from topic "new-TraceContext-newTimer-signature"

* changes:
  ReceiveCommits: Pull up trace context from processCommandsUnsafe into processCommands
  Log warning when LoggingContext of background thread is not initially empty
  Unset logging context before returning a thread back to the thread pool
  Add extension point to record execution times in a performance log
  TraceContext#newTimer: Separate message from arguments
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index ada208a..72b92a7 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2801,6 +2801,15 @@
 }
 ----
 
+[[performance-logger]]
+== Performance Logger
+
+`com.google.gerrit.server.logging.PerformanceLogger` is an extension point that
+is invoked for all operations for which the execution time is measured. The
+invocation of the extension point does not happen immediately, but only at the
+end of a request (REST call, SSH call, git push). Implementors can write the
+execution times into a performance log for further analysis.
+
 [[plugins_hosting]]
 == Plugins source code hosting
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 33daf46..efdf5ba 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -69,6 +69,7 @@
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -109,6 +110,8 @@
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -229,6 +232,7 @@
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
     final RestApiQuotaEnforcer quotaChecker;
+    final DynamicSet<PerformanceLogger> performanceLoggers;
 
     @Inject
     Globals(
@@ -239,7 +243,8 @@
         GroupAuditService auditService,
         RestApiMetrics metrics,
         RestApiQuotaEnforcer quotaChecker,
-        @GerritServerConfig Config cfg) {
+        @GerritServerConfig Config cfg,
+        DynamicSet<PerformanceLogger> performanceLoggers) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -247,6 +252,7 @@
       this.auditService = auditService;
       this.metrics = metrics;
       this.quotaChecker = quotaChecker;
+      this.performanceLoggers = performanceLoggers;
       allowOrigin = makeAllowOrigin(cfg);
     }
 
@@ -294,258 +300,266 @@
 
     try (TraceContext traceContext = enableTracing(req, res)) {
       try (PerThreadCache ignored = PerThreadCache.create()) {
-        logger.atFinest().log(
-            "Received REST request: %s %s (parameters: %s)",
-            req.getMethod(), req.getRequestURI(), getParameterNames(req));
-        logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
+        // It's important that the PerformanceLogContext is closed before the response is sent to
+        // the client. Only this way it is ensured that the invocation of the PerformanceLogger
+        // plugins happens before the client sees the response. This is needed for being able to
+        // test performance logging from an acceptance test (see
+        // TraceIT#performanceLoggingForRestCall()).
+        try (PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(globals.performanceLoggers)) {
+          logger.atFinest().log(
+              "Received REST request: %s %s (parameters: %s)",
+              req.getMethod(), req.getRequestURI(), getParameterNames(req));
+          logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
 
-        if (isCorsPreflight(req)) {
-          doCorsPreflight(req, res);
-          return;
-        }
-
-        qp = ParameterParser.getQueryParams(req);
-        checkCors(req, res, qp.hasXdOverride());
-        if (qp.hasXdOverride()) {
-          req = applyXdOverrides(req, qp);
-        }
-        checkUserSession(req);
-
-        List<IdString> path = splitPath(req);
-        RestCollection<RestResource, RestResource> rc = members.get();
-        globals
-            .permissionBackend
-            .currentUser()
-            .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
-
-        viewData = new ViewData(null, null);
-
-        if (path.isEmpty()) {
-          globals.quotaChecker.enforce(req);
-          if (rc instanceof NeedsParams) {
-            ((NeedsParams) rc).setParams(qp.params());
+          if (isCorsPreflight(req)) {
+            doCorsPreflight(req, res);
+            return;
           }
 
-          if (isRead(req)) {
-            viewData = new ViewData(null, rc.list());
-          } else if (isPost(req)) {
-            RestView<RestResource> restCollectionView =
-                rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
-            if (restCollectionView != null) {
-              viewData = new ViewData(null, restCollectionView);
+          qp = ParameterParser.getQueryParams(req);
+          checkCors(req, res, qp.hasXdOverride());
+          if (qp.hasXdOverride()) {
+            req = applyXdOverrides(req, qp);
+          }
+          checkUserSession(req);
+
+          List<IdString> path = splitPath(req);
+          RestCollection<RestResource, RestResource> rc = members.get();
+          globals
+              .permissionBackend
+              .currentUser()
+              .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
+
+          viewData = new ViewData(null, null);
+
+          if (path.isEmpty()) {
+            globals.quotaChecker.enforce(req);
+            if (rc instanceof NeedsParams) {
+              ((NeedsParams) rc).setParams(qp.params());
+            }
+
+            if (isRead(req)) {
+              viewData = new ViewData(null, rc.list());
+            } else if (isPost(req)) {
+              RestView<RestResource> restCollectionView =
+                  rc.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+              if (restCollectionView != null) {
+                viewData = new ViewData(null, restCollectionView);
+              } else {
+                throw methodNotAllowed(req);
+              }
             } else {
+              // DELETE on root collections is not supported
               throw methodNotAllowed(req);
             }
           } else {
-            // DELETE on root collections is not supported
-            throw methodNotAllowed(req);
-          }
-        } else {
-          IdString id = path.remove(0);
-          try {
-            rsrc = rc.parse(rsrc, id);
-            globals.quotaChecker.enforce(rsrc, req);
-            if (path.isEmpty()) {
-              checkPreconditions(req);
-            }
-          } catch (ResourceNotFoundException e) {
-            if (!path.isEmpty()) {
-              throw e;
-            }
-            globals.quotaChecker.enforce(req);
+            IdString id = path.remove(0);
+            try {
+              rsrc = rc.parse(rsrc, id);
+              globals.quotaChecker.enforce(rsrc, req);
+              if (path.isEmpty()) {
+                checkPreconditions(req);
+              }
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
+              globals.quotaChecker.enforce(req);
 
-            if (isPost(req) || isPut(req)) {
-              RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
-              if (createView != null) {
-                viewData = new ViewData(null, createView);
-                status = SC_CREATED;
-                path.add(id);
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(null, createView);
+                  status = SC_CREATED;
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> deleteView =
+                    rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+                if (deleteView != null) {
+                  viewData = new ViewData(null, deleteView);
+                  status = SC_NO_CONTENT;
+                  path.add(id);
+                } else {
+                  throw e;
+                }
               } else {
                 throw e;
               }
-            } else if (isDelete(req)) {
-              RestView<RestResource> deleteView =
-                  rc.views().get(PluginName.GERRIT, "DELETE_MISSING./");
-              if (deleteView != null) {
-                viewData = new ViewData(null, deleteView);
-                status = SC_NO_CONTENT;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else {
-              throw e;
             }
-          }
-          if (viewData.view == null) {
-            viewData = view(rc, req.getMethod(), path);
-          }
-        }
-        checkRequiresCapability(viewData);
-
-        while (viewData.view instanceof RestCollection<?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollection<RestResource, RestResource> c =
-              (RestCollection<RestResource, RestResource>) viewData.view;
-
-          if (path.isEmpty()) {
-            if (isRead(req)) {
-              viewData = new ViewData(null, c.list());
-            } else if (isPost(req)) {
-              // TODO: Here and on other collection methods: There is a bug that binds child views
-              // with pluginName="gerrit" instead of the real plugin name. This has never worked
-              // correctly and should be fixed where the binding gets created (DynamicMapProvider)
-              // and here.
-              RestView<RestResource> restCollectionView =
-                  c.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
-              if (restCollectionView != null) {
-                viewData = new ViewData(null, restCollectionView);
-              } else {
-                throw methodNotAllowed(req);
-              }
-            } else if (isDelete(req)) {
-              RestView<RestResource> restCollectionView =
-                  c.views().get(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
-              if (restCollectionView != null) {
-                viewData = new ViewData(null, restCollectionView);
-              } else {
-                throw methodNotAllowed(req);
-              }
-            } else {
-              throw methodNotAllowed(req);
+            if (viewData.view == null) {
+              viewData = view(rc, req.getMethod(), path);
             }
-            break;
-          }
-          IdString id = path.remove(0);
-          try {
-            rsrc = c.parse(rsrc, id);
-            checkPreconditions(req);
-            viewData = new ViewData(null, null);
-          } catch (ResourceNotFoundException e) {
-            if (!path.isEmpty()) {
-              throw e;
-            }
-
-            if (isPost(req) || isPut(req)) {
-              RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
-              if (createView != null) {
-                viewData = new ViewData(null, createView);
-                status = SC_CREATED;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else if (isDelete(req)) {
-              RestView<RestResource> deleteView =
-                  c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
-              if (deleteView != null) {
-                viewData = new ViewData(null, deleteView);
-                status = SC_NO_CONTENT;
-                path.add(id);
-              } else {
-                throw e;
-              }
-            } else {
-              throw e;
-            }
-          }
-          if (viewData.view == null) {
-            viewData = view(c, req.getMethod(), path);
           }
           checkRequiresCapability(viewData);
-        }
 
-        if (notModified(req, rsrc, viewData.view)) {
-          logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
-          res.sendError(SC_NOT_MODIFIED);
-          return;
-        }
+          while (viewData.view instanceof RestCollection<?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollection<RestResource, RestResource> c =
+                (RestCollection<RestResource, RestResource>) viewData.view;
 
-        if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-          return;
-        }
-
-        if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-          result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
-        } else if (viewData.view instanceof RestModifyView<?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestModifyView<RestResource, Object> m =
-              (RestModifyView<RestResource, Object>) viewData.view;
-
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+            if (path.isEmpty()) {
+              if (isRead(req)) {
+                viewData = new ViewData(null, c.list());
+              } else if (isPost(req)) {
+                // TODO: Here and on other collection methods: There is a bug that binds child views
+                // with pluginName="gerrit" instead of the real plugin name. This has never worked
+                // correctly and should be fixed where the binding gets created (DynamicMapProvider)
+                // and here.
+                RestView<RestResource> restCollectionView =
+                    c.views().get(PluginName.GERRIT, "POST_ON_COLLECTION./");
+                if (restCollectionView != null) {
+                  viewData = new ViewData(null, restCollectionView);
+                } else {
+                  throw methodNotAllowed(req);
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> restCollectionView =
+                    c.views().get(PluginName.GERRIT, "DELETE_ON_COLLECTION./");
+                if (restCollectionView != null) {
+                  viewData = new ViewData(null, restCollectionView);
+                } else {
+                  throw methodNotAllowed(req);
+                }
+              } else {
+                throw methodNotAllowed(req);
+              }
+              break;
             }
-          }
-        } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionCreateView<RestResource, RestResource, Object> m =
-              (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+            IdString id = path.remove(0);
+            try {
+              rsrc = c.parse(rsrc, id);
+              checkPreconditions(req);
+              viewData = new ViewData(null, null);
+            } catch (ResourceNotFoundException e) {
+              if (!path.isEmpty()) {
+                throw e;
+              }
 
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, path.get(0), inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
+              if (isPost(req) || isPut(req)) {
+                RestView<RestResource> createView = c.views().get(PluginName.GERRIT, "CREATE./");
+                if (createView != null) {
+                  viewData = new ViewData(null, createView);
+                  status = SC_CREATED;
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else if (isDelete(req)) {
+                RestView<RestResource> deleteView =
+                    c.views().get(PluginName.GERRIT, "DELETE_MISSING./");
+                if (deleteView != null) {
+                  viewData = new ViewData(null, deleteView);
+                  status = SC_NO_CONTENT;
+                  path.add(id);
+                } else {
+                  throw e;
+                }
+              } else {
+                throw e;
+              }
             }
+            if (viewData.view == null) {
+              viewData = view(c, req.getMethod(), path);
+            }
+            checkRequiresCapability(viewData);
           }
-        } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-              (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
 
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, path.get(0), inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
-            }
+          if (notModified(req, rsrc, viewData.view)) {
+            logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
+            res.sendError(SC_NOT_MODIFIED);
+            return;
           }
-        } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-          @SuppressWarnings("unchecked")
-          RestCollectionModifyView<RestResource, RestResource, Object> m =
-              (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
-          Type type = inputType(m);
-          inputRequestBody = parseRequest(req, type);
-          result = m.apply(rsrc, inputRequestBody);
-          if (inputRequestBody instanceof RawInput) {
-            try (InputStream is = req.getInputStream()) {
-              ServletUtils.consumeRequestBody(is);
-            }
+          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
+            return;
           }
-        } else {
-          throw new ResourceNotFoundException();
+
+          if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+            result = ((RestReadView<RestResource>) viewData.view).apply(rsrc);
+          } else if (viewData.view instanceof RestModifyView<?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestModifyView<RestResource, Object> m =
+                (RestModifyView<RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            result = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionCreateView<RestResource, RestResource, Object> m =
+                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            result = m.apply(rsrc, path.get(0), inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            result = m.apply(rsrc, path.get(0), inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+            @SuppressWarnings("unchecked")
+            RestCollectionModifyView<RestResource, RestResource, Object> m =
+                (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+            Type type = inputType(m);
+            inputRequestBody = parseRequest(req, type);
+            result = m.apply(rsrc, inputRequestBody);
+            if (inputRequestBody instanceof RawInput) {
+              try (InputStream is = req.getInputStream()) {
+                ServletUtils.consumeRequestBody(is);
+              }
+            }
+          } else {
+            throw new ResourceNotFoundException();
+          }
+
+          if (result instanceof Response) {
+            @SuppressWarnings("rawtypes")
+            Response<?> r = (Response) result;
+            status = r.statusCode();
+            configureCaching(req, res, rsrc, viewData.view, r.caching());
+          } else if (result instanceof Response.Redirect) {
+            CacheHeaders.setNotCacheable(res);
+            String location = ((Response.Redirect) result).location();
+            res.sendRedirect(location);
+            logger.atFinest().log("REST call redirected to: %s", location);
+            return;
+          } else if (result instanceof Response.Accepted) {
+            CacheHeaders.setNotCacheable(res);
+            res.setStatus(SC_ACCEPTED);
+            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
+            logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
+            return;
+          } else {
+            CacheHeaders.setNotCacheable(res);
+          }
+          res.setStatus(status);
+          logger.atFinest().log("REST call succeeded: %d", status);
         }
 
-        if (result instanceof Response) {
-          @SuppressWarnings("rawtypes")
-          Response<?> r = (Response) result;
-          status = r.statusCode();
-          configureCaching(req, res, rsrc, viewData.view, r.caching());
-        } else if (result instanceof Response.Redirect) {
-          CacheHeaders.setNotCacheable(res);
-          String location = ((Response.Redirect) result).location();
-          res.sendRedirect(location);
-          logger.atFinest().log("REST call redirected to: %s", location);
-          return;
-        } else if (result instanceof Response.Accepted) {
-          CacheHeaders.setNotCacheable(res);
-          res.setStatus(SC_ACCEPTED);
-          res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location());
-          logger.atFinest().log("REST call succeeded: %d", SC_ACCEPTED);
-          return;
-        } else {
-          CacheHeaders.setNotCacheable(res);
-        }
-        res.setStatus(status);
-        logger.atFinest().log("REST call succeeded: %d", status);
-
         if (result != Response.none()) {
           result = Response.unwrap(result);
           if (result instanceof BinaryResult) {
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index b7b9981..29b0a92 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -6,6 +6,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 2134488..d0033a4 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -18,6 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -68,7 +70,10 @@
    * @param unit time unit of the value
    */
   public final void record(long value, TimeUnit unit) {
-    logger.atFinest().log("%s took %dms", name, unit.toMillis(value));
+    long durationMs = unit.toMillis(value);
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
+    logger.atFinest().log("%s took %dms", name, durationMs);
     doRecord(value, unit);
   }
 
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index 16c151e..01ca768 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -18,6 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -75,7 +77,14 @@
    * @param unit time unit of the value
    */
   public final void record(F1 field1, long value, TimeUnit unit) {
-    logger.atFinest().log("%s (%s) took %dms", name, field1, unit.toMillis(value));
+    long durationMs = unit.toMillis(value);
+
+    // TODO(ekempin): We don't know the field name here. Check whether we can make it available.
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(
+            () -> PerformanceLogRecord.create(name, durationMs, "field1", field1));
+
+    logger.atFinest().log("%s (%s) took %dms", name, field1, durationMs);
     doRecord(field1, value, unit);
   }
 
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index bf19448..bacbcdf 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -18,6 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -80,7 +82,15 @@
    * @param unit time unit of the value
    */
   public final void record(F1 field1, F2 field2, long value, TimeUnit unit) {
-    logger.atFinest().log("%s (%s, %s) took %dms", name, field1, field2, unit.toMillis(value));
+    long durationMs = unit.toMillis(value);
+
+    // TODO(ekempin): We don't know the field names here. Check whether we can make them available.
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(
+            () ->
+                PerformanceLogRecord.create(name, durationMs, "field1", field1, "field2", field2));
+
+    logger.atFinest().log("%s (%s, %s) took %dms", name, field1, field2, durationMs);
     doRecord(field1, field2, value, unit);
   }
 
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index c910eb0..27e224b 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -18,6 +18,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -85,8 +87,16 @@
    * @param unit time unit of the value
    */
   public final void record(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
-    logger.atFinest().log(
-        "%s (%s, %s, %s) took %dms", name, field1, field2, field3, unit.toMillis(value));
+    long durationMs = unit.toMillis(value);
+
+    // TODO(ekempin): We don't know the field names here. Check whether we can make them available.
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(
+            () ->
+                PerformanceLogRecord.create(
+                    name, durationMs, "field1", field1, "field2", field2, "field3", field3));
+
+    logger.atFinest().log("%s (%s, %s, %s) took %dms", name, field1, field2, field3, durationMs);
     doRecord(field1, field2, field3, value, unit);
   }
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 736e786..2cc4fb2 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -136,6 +136,7 @@
         ":server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//lib:blame-cache",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index d092ac8..9b43724 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -376,7 +376,7 @@
   }
 
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
-    try (TraceTimer traceTimer = TraceContext.newTimer("Read star labels from %s", refName)) {
+    try (TraceTimer traceTimer = TraceContext.newTimer("Read star labels", "ref", refName)) {
       Ref ref = repo.exactRef(refName);
       if (ref == null) {
         return StarRef.MISSING;
@@ -450,7 +450,7 @@
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
       throws IOException, InvalidLabelsException {
     try (TraceTimer traceTimer =
-            TraceContext.newTimer("Update star labels in %s (labels=%s)", refName, labels);
+            TraceContext.newTimer("Update star labels", "ref", refName, "labels", labels);
         RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -487,7 +487,7 @@
       return;
     }
 
-    try (TraceTimer traceTimer = TraceContext.newTimer("Delete star labels in %s", refName)) {
+    try (TraceTimer traceTimer = TraceContext.newTimer("Delete star labels", "ref", refName)) {
       RefUpdate u = repo.updateRef(refName);
       u.setForceUpdate(true);
       u.setExpectedOldObjectId(oldObjectId);
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index fd48fa7..3f3f27d 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -183,7 +183,7 @@
 
     @Override
     public Optional<AccountState> load(Account.Id who) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading account %s", who)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading account", "accountId", who)) {
         return accounts.get(who);
       }
     }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index b9cfb61..7ed7ebc 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -149,7 +149,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group %s by ID", key)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading group by ID", "groupId", key)) {
         return groupQueryProvider.get().byId(key);
       }
     }
@@ -165,7 +165,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group '%s' by name", name)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading group by name", "groupName", name)) {
         return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
       }
     }
@@ -181,7 +181,7 @@
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading group %s by UUID", uuid)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading group by UUID", "groupUuid", uuid)) {
         return groups.getGroup(AccountGroup.uuid(uuid));
       }
     }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index c27d6c3..3507ba2 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -152,7 +152,8 @@
 
     @Override
     public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId) {
-      try (TraceTimer timer = TraceContext.newTimer("Loading groups with member %s", memberId)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer("Loading groups with member", "memberId", memberId)) {
         return groupQueryProvider.get().byMember(memberId).stream()
             .map(InternalGroup::getGroupUUID)
             .collect(toImmutableSet());
@@ -171,7 +172,7 @@
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) {
-      try (TraceTimer timer = TraceContext.newTimer("Loading parent groups of %s", key)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading parent groups", "groupUuid", key)) {
         return groupQueryProvider.get().bySubgroup(key).stream()
             .map(InternalGroup::getGroupUUID)
             .collect(toImmutableList());
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 5aa19d8..ad861c0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -157,8 +157,7 @@
 
     @Override
     public AllExternalIds load(ObjectId notesRev) throws Exception {
-      try (TraceTimer timer =
-          TraceContext.newTimer("Loading external IDs (revision=%s)", notesRev)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading external IDs", "revision", notesRev)) {
         ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
         externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
         return AllExternalIds.create(externalIds);
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index ed446f2..c12aaf5 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -353,7 +353,8 @@
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading account for username %s", username)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer("Loading account for username", "username", username)) {
         return externalIds
             .get(ExternalId.Key.create(SCHEME_GERRIT, username))
             .map(ExternalId::accountId);
@@ -372,7 +373,7 @@
     @Override
     public Set<AccountGroup.UUID> load(String username) throws Exception {
       try (TraceTimer timer =
-          TraceContext.newTimer("Loading group for member with username %s", username)) {
+          TraceContext.newTimer("Loading group for member with username", "username", username)) {
         final DirContext ctx = helper.open();
         try {
           return helper.queryForGroups(ctx, username, null);
@@ -393,7 +394,7 @@
 
     @Override
     public Boolean load(String groupDn) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading groupDn %s", groupDn)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading groupDn", "groupDn", groupDn)) {
         final DirContext ctx = helper.open();
         try {
           Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 3732e37..2b44019 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -237,7 +237,7 @@
 
     @Override
     public ValueHolder<V> load(K key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading value for %s from cache", key)) {
+      try (TraceTimer timer = TraceContext.newTimer("Loading value from cache", "key", key)) {
         if (store.mightContain(key)) {
           ValueHolder<V> h = store.getIfPresent(key);
           if (h != null) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 59a55ab..14747e5 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -136,6 +136,7 @@
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.db.GroupDbModule;
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.mail.AutoReplyMailFilter;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
@@ -380,6 +381,7 @@
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
+    DynamicSet.setOf(binder(), PerformanceLogger.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 2faa0bb..4937713 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -151,7 +151,7 @@
     @Override
     public Boolean load(PureRevertKeyProto key) throws BadRequestException, IOException {
       try (TraceContext.TraceTimer ignored =
-          TraceContext.newTimer("Loading pure revert for %s", key)) {
+          TraceContext.newTimer("Loading pure revert", "key", key)) {
         ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
         ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
         Project.NameKey project = Project.nameKey(key.getProject());
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index fb7756f..15284fe 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -152,7 +152,8 @@
 
     @Override
     public List<CachedChange> load(Project.NameKey key) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading changes of project %s", key);
+      try (TraceTimer timer =
+              TraceContext.newTimer("Loading changes of project", "projectName", key);
           ManualRequestContext ctx = requestContext.open()) {
         List<ChangeData> cds =
             queryProvider
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 9506efc..6356068 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -499,8 +499,15 @@
 
     try (TraceTimer timer =
             TraceContext.newTimer(
-                "Read file '%s' from ref '%s' of project '%s' from revision '%s'",
-                fileName, getRefName(), projectName, revision.name());
+                "Read file",
+                "fileName",
+                fileName,
+                "ref",
+                getRefName(),
+                "projectName",
+                projectName,
+                "revision",
+                revision.name());
         TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
       if (tw != null) {
         ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
@@ -575,7 +582,7 @@
   protected void saveFile(String fileName, byte[] raw) throws IOException {
     try (TraceTimer timer =
         TraceContext.newTimer(
-            "Save file '%s' in ref '%s' of project '%s'", fileName, getRefName(), projectName)) {
+            "Save file", "fileName", fileName, "ref", getRefName(), "projectName", projectName)) {
       DirCacheEditor editor = newTree.editor();
       if (raw != null && 0 < raw.length) {
         final ObjectId blobId = inserter.insert(Constants.OBJ_BLOB, raw);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 73e0b1c..98ed97a 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -74,6 +74,7 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -118,6 +119,8 @@
 import com.google.gerrit.server.git.validators.RefOperationValidators;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
@@ -306,6 +309,7 @@
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Provider<InternalChangeQuery> queryProvider;
@@ -382,6 +386,7 @@
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
+      DynamicSet<PerformanceLogger> performanceLoggers,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<InternalChangeQuery> queryProvider,
@@ -427,6 +432,7 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
+    this.performanceLoggers = performanceLoggers;
     this.queryProvider = queryProvider;
     this.receiveConfig = receiveConfig;
     this.refValidatorsFactory = refValidatorsFactory;
@@ -506,117 +512,120 @@
   }
 
   void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
-    commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-    processCommandsUnsafe(commands, progress);
-    rejectRemaining(commands, "internal server error");
-
-    // This sends error messages before the 'done' string of the progress monitor is sent.
-    // Currently, the test framework relies on this ordering to understand if pushes completed
-    // successfully.
-    sendErrorMessages();
-
-    commandProgress.end();
-    progress.end();
-  }
-
-  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
-  private void processCommandsUnsafe(
-      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     parsePushOptions();
     try (TraceContext traceContext =
-        TraceContext.newTrace(
-            tracePushOption.isPresent(),
-            tracePushOption.orElse(null),
-            (tagName, traceId) -> addMessage(tagName + ": " + traceId))) {
+            TraceContext.newTrace(
+                tracePushOption.isPresent(),
+                tracePushOption.orElse(null),
+                (tagName, traceId) -> addMessage(tagName + ": " + traceId));
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(performanceLoggers)) {
       traceContext.addTag(RequestId.Type.RECEIVE_ID, new RequestId(project.getNameKey().get()));
 
-      logger.atFinest().log("Calling user: %s", user.getLoggableName());
-
       // Log the push options here, rather than in parsePushOptions(), so that they are included
       // into the trace if tracing is enabled.
       logger.atFine().log("push options: %s", receivePack.getPushOptions());
 
-      if (!projectState.getProject().getState().permitsWrite()) {
-        for (ReceiveCommand cmd : commands) {
-          reject(cmd, "prohibited by Gerrit: project state does not permit write");
-        }
-        return;
-      }
+      Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
+      commands =
+          commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
+      processCommandsUnsafe(commands, progress);
+      rejectRemaining(commands, "internal server error");
 
-      logger.atFine().log("Parsing %d commands", commands.size());
+      // This sends error messages before the 'done' string of the progress monitor is sent.
+      // Currently, the test framework relies on this ordering to understand if pushes completed
+      // successfully.
+      sendErrorMessages();
 
-      List<ReceiveCommand> magicCommands = new ArrayList<>();
-      List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
-      List<ReceiveCommand> regularCommands = new ArrayList<>();
-
-      for (ReceiveCommand cmd : commands) {
-        if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-          magicCommands.add(cmd);
-        } else if (isDirectChangesPush(cmd.getRefName())) {
-          directPatchSetPushCommands.add(cmd);
-        } else {
-          regularCommands.add(cmd);
-        }
-      }
-
-      int commandTypes =
-          (magicCommands.isEmpty() ? 0 : 1)
-              + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
-              + (regularCommands.isEmpty() ? 0 : 1);
-
-      if (commandTypes > 1) {
-        rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
-        return;
-      }
-
-      try {
-        if (!regularCommands.isEmpty()) {
-          handleRegularCommands(regularCommands, progress);
-          return;
-        }
-
-        for (ReceiveCommand cmd : directPatchSetPushCommands) {
-          parseDirectChangesPush(cmd);
-        }
-
-        boolean first = true;
-        for (ReceiveCommand cmd : magicCommands) {
-          if (first) {
-            parseMagicBranch(cmd);
-            first = false;
-          } else {
-            reject(cmd, "duplicate request");
-          }
-        }
-      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
-        return;
-      }
-
-      Task newProgress = progress.beginSubTask("new", UNKNOWN);
-      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-
-      List<CreateRequest> newChanges = Collections.emptyList();
-      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-        newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
-      }
-
-      // Commit validation has already happened, so any changes without Change-Id are for the
-      // deprecated feature.
-      warnAboutMissingChangeId(newChanges);
-      preparePatchSetsForReplace(newChanges);
-      insertChangesAndPatchSets(newChanges, replaceProgress);
-      newProgress.end();
-      replaceProgress.end();
-      queueSuccessMessages(newChanges);
-
-      logger.atFine().log(
-          "Command results: %s",
-          lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+      commandProgress.end();
+      progress.end();
     }
   }
 
+  // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
+  private void processCommandsUnsafe(
+      Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    logger.atFinest().log("Calling user: %s", user.getLoggableName());
+
+    if (!projectState.getProject().getState().permitsWrite()) {
+      for (ReceiveCommand cmd : commands) {
+        reject(cmd, "prohibited by Gerrit: project state does not permit write");
+      }
+      return;
+    }
+
+    logger.atFine().log("Parsing %d commands", commands.size());
+
+    List<ReceiveCommand> magicCommands = new ArrayList<>();
+    List<ReceiveCommand> directPatchSetPushCommands = new ArrayList<>();
+    List<ReceiveCommand> regularCommands = new ArrayList<>();
+
+    for (ReceiveCommand cmd : commands) {
+      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+        magicCommands.add(cmd);
+      } else if (isDirectChangesPush(cmd.getRefName())) {
+        directPatchSetPushCommands.add(cmd);
+      } else {
+        regularCommands.add(cmd);
+      }
+    }
+
+    int commandTypes =
+        (magicCommands.isEmpty() ? 0 : 1)
+            + (directPatchSetPushCommands.isEmpty() ? 0 : 1)
+            + (regularCommands.isEmpty() ? 0 : 1);
+
+    if (commandTypes > 1) {
+      rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+      return;
+    }
+
+    try {
+      if (!regularCommands.isEmpty()) {
+        handleRegularCommands(regularCommands, progress);
+        return;
+      }
+
+      for (ReceiveCommand cmd : directPatchSetPushCommands) {
+        parseDirectChangesPush(cmd);
+      }
+
+      boolean first = true;
+      for (ReceiveCommand cmd : magicCommands) {
+        if (first) {
+          parseMagicBranch(cmd);
+          first = false;
+        } else {
+          reject(cmd, "duplicate request");
+        }
+      }
+    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+      logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+      return;
+    }
+
+    Task newProgress = progress.beginSubTask("new", UNKNOWN);
+    Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+    List<CreateRequest> newChanges = Collections.emptyList();
+    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+      newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
+    }
+
+    // Commit validation has already happened, so any changes without Change-Id are for the
+    // deprecated feature.
+    warnAboutMissingChangeId(newChanges);
+    preparePatchSetsForReplace(newChanges);
+    insertChangesAndPatchSets(newChanges, replaceProgress);
+    newProgress.end();
+    replaceProgress.end();
+    queueSuccessMessages(newChanges);
+
+    logger.atFine().log(
+        "Command results: %s",
+        lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
+  }
+
   private void sendErrorMessages() {
     if (!errors.isEmpty()) {
       logger.atFine().log("Handling error conditions: %s", errors.keySet());
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index b450ab8..2f9ce01 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -265,7 +265,9 @@
       throws DuplicateKeyException, IOException, ConfigInvalidException {
     try (TraceTimer timer =
         TraceContext.newTimer(
-            "Creating group '%s'", groupUpdate.getName().orElseGet(groupCreation::getNameKey))) {
+            "Creating group",
+            "groupName",
+            groupUpdate.getName().orElseGet(groupCreation::getNameKey))) {
       InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
       evictCachesOnGroupCreation(createdGroup);
       dispatchAuditEventsOnGroupCreation(createdGroup);
@@ -285,7 +287,7 @@
    */
   public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    try (TraceTimer timer = TraceContext.newTimer("Updating group %s", groupUuid)) {
+    try (TraceTimer timer = TraceContext.newTimer("Updating group", "groupUuid", groupUuid)) {
       Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
       if (!updatedOn.isPresent()) {
         updatedOn = Optional.of(TimeUtil.nowTs());
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 1eaac7a..da22eac 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -90,13 +90,21 @@
       if (accountState.isPresent()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Replacing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Replacing account in index",
+                "accountId",
+                id.get(),
+                "indexVersion",
+                i.getSchema().getVersion())) {
           i.replace(accountState.get());
         }
       } else {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleteing account %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Deleting account in index",
+                "accountId",
+                id.get(),
+                "indexVersion",
+                i.getSchema().getVersion())) {
           i.delete(id);
         }
       }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 348e0ce..e1c7dc9 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -174,8 +174,11 @@
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       try (TraceTimer traceTimer =
           TraceContext.newTimer(
-              "Replacing change %d in index version %d",
-              cd.getId().get(), i.getSchema().getVersion())) {
+              "Replacing change in index",
+              "changeId",
+              cd.getId().get(),
+              "indexVersion",
+              i.getSchema().getVersion())) {
         i.replace(cd);
       }
     }
@@ -336,7 +339,11 @@
       for (ChangeIndex i : getWriteIndexes()) {
         try (TraceTimer traceTimer =
             TraceContext.newTimer(
-                "Deleteing change %d in index version %d", id.get(), i.getSchema().getVersion())) {
+                "Deleting change in index",
+                "changeId",
+                id.get(),
+                "indexVersion",
+                i.getSchema().getVersion())) {
           i.delete(id);
         }
       }
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index cf8e9db..5ab8e69 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -6,10 +6,12 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
+        "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 1e81c29..7f5cee3 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -14,8 +14,14 @@
 
 package com.google.gerrit.server.logging;
 
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.flogger.backend.Tags;
+import com.google.inject.Provider;
+import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.logging.Level;
 
@@ -35,6 +41,9 @@
 
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
+      new ThreadLocal<>();
 
   private LoggingContext() {}
 
@@ -47,14 +56,42 @@
     if (runnable instanceof LoggingContextAwareRunnable) {
       return runnable;
     }
-    return new LoggingContextAwareRunnable(runnable);
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareRunnable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareRunnable(
+        runnable, getInstance().getMutablePerformanceLogRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
     if (callable instanceof LoggingContextAwareCallable) {
       return callable;
     }
-    return new LoggingContextAwareCallable<>(callable);
+
+    // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareCallable
+    // constructor so that performance log records that are created in the wrapped runnable are
+    // added to this MutablePerformanceLogRecords instance. This is important since performance
+    // log records are processed only at the end of the request and performance log records that
+    // are created in another thread should not get lost.
+    return new LoggingContextAwareCallable<>(
+        callable, getInstance().getMutablePerformanceLogRecords());
+  }
+
+  public boolean isEmpty() {
+    return tags.get() == null
+        && forceLogging.get() == null
+        && performanceLogging.get() == null
+        && performanceLogRecords == null;
+  }
+
+  public void clear() {
+    tags.remove();
+    forceLogging.remove();
+    performanceLogging.remove();
+    performanceLogRecords.remove();
   }
 
   @Override
@@ -120,4 +157,113 @@
     }
     return oldValue != null ? oldValue : false;
   }
+
+  boolean isPerformanceLogging() {
+    Boolean isPerformanceLogging = performanceLogging.get();
+    return isPerformanceLogging != null ? isPerformanceLogging : false;
+  }
+
+  /**
+   * Enables performance logging.
+   *
+   * <p>It's important to enable performance logging only in a context that ensures to consume the
+   * captured performance log records. Otherwise captured performance log records might leak into
+   * other requests that are executed by the same thread (if a thread pool is used to process
+   * requests).
+   *
+   * @param enable whether performance logging should be enabled.
+   * @return whether performance logging was be enabled before invoking this method (old value).
+   */
+  boolean performanceLogging(boolean enable) {
+    Boolean oldValue = performanceLogging.get();
+    if (enable) {
+      performanceLogging.set(true);
+    } else {
+      performanceLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds a performance log record, if performance logging is enabled.
+   *
+   * @param recordProvider Provider for the performance log record. This provider is only invoked if
+   *     performance logging is enabled. This means if performance logging is disabled, we avoid the
+   *     creation of a {@link PerformanceLogRecord}.
+   */
+  public void addPerformanceLogRecord(Provider<PerformanceLogRecord> recordProvider) {
+    if (!isPerformanceLogging()) {
+      // return early and avoid the creation of a PerformanceLogRecord
+      return;
+    }
+
+    getMutablePerformanceLogRecords().add(recordProvider.get());
+  }
+
+  ImmutableList<PerformanceLogRecord> getPerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearPerformanceLogEntries() {
+    performanceLogRecords.remove();
+  }
+
+  /**
+   * Set the performance log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newPerformanceLogRecords performance log records that should be set
+   */
+  void setPerformanceLogRecords(List<PerformanceLogRecord> newPerformanceLogRecords) {
+    if (newPerformanceLogRecords.isEmpty()) {
+      performanceLogRecords.remove();
+      return;
+    }
+
+    getMutablePerformanceLogRecords().set(newPerformanceLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutablePerformanceLogRecords} instance for storing performance log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutablePerformanceLogRecords} instance is
+   * directly stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the performance log records that are added in the new thread are added to the
+   * same {@link MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and
+   * {@link LoggingContextAwareCallable}). This is important since performance log records are
+   * processed only at the end of the request and performance log records that are created in
+   * another thread should not get lost.
+   *
+   * @param mutablePerformanceLogRecords the {@link MutablePerformanceLogRecords} instance in which
+   *     performance log records should be stored
+   */
+  void setMutablePerformanceLogRecords(MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+    performanceLogRecords.set(requireNonNull(mutablePerformanceLogRecords));
+  }
+
+  private MutablePerformanceLogRecords getMutablePerformanceLogRecords() {
+    MutablePerformanceLogRecords records = performanceLogRecords.get();
+    if (records == null) {
+      records = new MutablePerformanceLogRecords();
+      performanceLogRecords.set(records);
+    }
+    return records;
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("tags", tags.get())
+        .add("forceLogging", forceLogging.get())
+        .add("performanceLogging", performanceLogging.get())
+        .add("performanceLogRecords", performanceLogRecords.get())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 6aff5c4..d2701d7 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
 import java.util.concurrent.Callable;
 
 /**
@@ -31,16 +32,30 @@
  * @see LoggingContextAwareRunnable
  */
 class LoggingContextAwareCallable<T> implements Callable<T> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Callable<T> callable;
   private final Thread callingThread;
   private final ImmutableSetMultimap<String, String> tags;
   private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
 
-  LoggingContextAwareCallable(Callable<T> callable) {
+  /**
+   * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
+   *
+   * @param callable Callable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareCallable(
+      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
   }
 
   @Override
@@ -50,17 +65,29 @@
       return callable.call();
     }
 
-    // propagate logging context
     LoggingContext loggingCtx = LoggingContext.getInstance();
-    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
-    boolean oldForceLogging = loggingCtx.isLoggingForced();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       return callable.call();
     } finally {
-      loggingCtx.setTags(oldTags);
-      loggingCtx.forceLogging(oldForceLogging);
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index 0bd7d00..23162b1 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.flogger.FluentLogger;
 
 /**
  * Wrapper for a {@link Runnable} that copies the {@link LoggingContext} from the current thread to
@@ -49,16 +50,30 @@
  * @see LoggingContextAwareCallable
  */
 public class LoggingContextAwareRunnable implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Runnable runnable;
   private final Thread callingThread;
   private final ImmutableSetMultimap<String, String> tags;
   private final boolean forceLogging;
+  private final boolean performanceLogging;
+  private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
 
-  LoggingContextAwareRunnable(Runnable runnable) {
+  /**
+   * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
+   *
+   * @param runnable Runnable that should be wrapped.
+   * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
+   *     performance log records that are created from the runnable are added
+   */
+  LoggingContextAwareRunnable(
+      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
+    this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
   }
 
   public Runnable unwrap() {
@@ -73,17 +88,29 @@
       return;
     }
 
-    // propagate logging context
     LoggingContext loggingCtx = LoggingContext.getInstance();
-    ImmutableSetMultimap<String, String> oldTags = loggingCtx.getTagsAsMap();
-    boolean oldForceLogging = loggingCtx.isLoggingForced();
+
+    if (!loggingCtx.isEmpty()) {
+      logger.atWarning().log("Logging context is not empty: %s", loggingCtx);
+    }
+
+    // propagate logging context
     loggingCtx.setTags(tags);
     loggingCtx.forceLogging(forceLogging);
+    loggingCtx.performanceLogging(performanceLogging);
+
+    // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
+    // the logging context of the calling thread in the logging context of the new thread. This way
+    // performance log records that are created from the new thread are available from the logging
+    // context of the calling thread. This is important since performance log records are processed
+    // only at the end of the request and performance log records that are created in another thread
+    // should not get lost.
+    loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
     try {
       runnable.run();
     } finally {
-      loggingCtx.setTags(oldTags);
-      loggingCtx.forceLogging(oldForceLogging);
+      // Cleanup logging context. This is important if the thread is pooled and reused.
+      loggingCtx.clear();
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
new file mode 100644
index 0000000..4ee70d7
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for performance log records.
+ *
+ * <p>This class is intended to keep track of performance log records in {@link LoggingContext}. It
+ * needs to be thread-safe because it gets shared between threads when the logging context is copied
+ * to another thread (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}. In this case the logging contexts of both threads share the same
+ * instance of this class. This is important since performance log records are processed only at the
+ * end of a request and performance log records that are created in another thread should not get
+ * lost.
+ */
+public class MutablePerformanceLogRecords {
+  private final ArrayList<PerformanceLogRecord> performanceLogRecords = new ArrayList<>();
+
+  public synchronized void add(PerformanceLogRecord record) {
+    performanceLogRecords.add(record);
+  }
+
+  public synchronized void set(List<PerformanceLogRecord> records) {
+    performanceLogRecords.clear();
+    performanceLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<PerformanceLogRecord> list() {
+    return ImmutableList.copyOf(performanceLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("performanceLogRecords", performanceLogRecords)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
index f70a8db..83009a6 100644
--- a/java/com/google/gerrit/server/logging/MutableTags.java
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -110,4 +111,10 @@
     tagMap.forEach(tagsBuilder::addTag);
     tags = tagsBuilder.build();
   }
+
+  @Override
+  public String toString() {
+    buildTags();
+    return MoreObjects.toStringHelper(this).add("tags", tags).toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
new file mode 100644
index 0000000..2f5c789
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
+
+/**
+ * Context for capturing performance log records. When the context is closed the performance log
+ * records are handed over to the registered {@link PerformanceLogger}s.
+ *
+ * <p>Capturing performance log records is disabled if there are no {@link PerformanceLogger}
+ * registered (in this case the captured performance log records would never be used).
+ *
+ * <p>It's important to enable capturing of performance log records in a context that ensures to
+ * consume the captured performance log records. Otherwise captured performance log records might
+ * leak into other requests that are executed by the same thread (if a thread pool is used to
+ * process requests).
+ */
+public class PerformanceLogContext implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Do not use PluginSetContext. PluginSetContext traces the plugin latency with a timer metric
+  // which would result in a performance log and we don't want to log the performance of writing
+  // a performance log in the performance log (endless loop).
+  private final DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private final boolean oldPerformanceLogging;
+  private final ImmutableList<PerformanceLogRecord> oldPerformanceLogRecords;
+
+  public PerformanceLogContext(DynamicSet<PerformanceLogger> performanceLoggers) {
+    this.performanceLoggers = performanceLoggers;
+
+    // Just in case remember the old state and reset performance log entries.
+    this.oldPerformanceLogging = LoggingContext.getInstance().isPerformanceLogging();
+    this.oldPerformanceLogRecords = LoggingContext.getInstance().getPerformanceLogRecords();
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+
+    // Do not create performance log entries if no PerformanceLogger is registered.
+    LoggingContext.getInstance()
+        .performanceLogging(!Iterables.isEmpty(performanceLoggers.entries()));
+  }
+
+  @Override
+  public void close() {
+    if (LoggingContext.getInstance().isPerformanceLogging()) {
+      runEach(performanceLoggers, LoggingContext.getInstance().getPerformanceLogRecords());
+    }
+
+    // Restore old state. Required to support nesting of PerformanceLogContext's.
+    LoggingContext.getInstance().performanceLogging(oldPerformanceLogging);
+    LoggingContext.getInstance().setPerformanceLogRecords(oldPerformanceLogRecords);
+  }
+
+  /**
+   * Invokes all performance loggers.
+   *
+   * <p>Similar to how {@code com.google.gerrit.server.plugincontext.PluginContext} invokes plugins
+   * but without recording metrics for invoking {@link PerformanceLogger}s.
+   *
+   * @param performanceLoggers the performance loggers that should be invoked
+   * @param performanceLogRecords the performance log records that should be handed over to the
+   *     performance loggers
+   */
+  private static void runEach(
+      DynamicSet<PerformanceLogger> performanceLoggers,
+      ImmutableList<PerformanceLogRecord> performanceLogRecords) {
+    performanceLoggers
+        .entries()
+        .forEach(
+            p -> {
+              try (TraceContext traceContext = newPluginTrace(p)) {
+                performanceLogRecords.forEach(r -> r.writeTo(p.get()));
+              } catch (Throwable e) {
+                logger.atWarning().withCause(e).log(
+                    "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
+              }
+            });
+  }
+
+  /**
+   * Opens a trace context for a plugin that implements {@link PerformanceLogger}.
+   *
+   * <p>Basically the same as {@code
+   * com.google.gerrit.server.plugincontext.PluginContext#newTrace(Extension<T>)}. We have this
+   * method here to avoid a dependency on PluginContext which lives in
+   * "//java/com/google/gerrit/server". This package ("//java/com/google/gerrit/server/logging")
+   * should have as few dependencies as possible.
+   *
+   * @param extension performance logger extension
+   * @return the trace context
+   */
+  private static TraceContext newPluginTrace(Extension<PerformanceLogger> extension) {
+    return TraceContext.open().addPluginTag(extension.getPluginName());
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
new file mode 100644
index 0000000..d30f862
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -0,0 +1,220 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/**
+ * The record of an operation for which the execution time was measured.
+ *
+ * <p>Meta data is stored in separate key/value fields to avoid expensive instantiations of Map
+ * objects.
+ */
+@AutoValue
+public abstract class PerformanceLogRecord {
+  /**
+   * Creates a performance log record without meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(String operation, long durationMs) {
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, null, null, null, null, null, null, null, null);
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param key meta data key
+   * @param value meta data value
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(
+      String operation, long durationMs, String key, @Nullable Object value) {
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, requireNonNull(key), value, null, null, null, null, null, null);
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2) {
+    return new AutoValue_PerformanceLogRecord(
+        operation,
+        durationMs,
+        requireNonNull(key1),
+        value1,
+        requireNonNull(key2),
+        value2,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3) {
+    return new AutoValue_PerformanceLogRecord(
+        operation,
+        durationMs,
+        requireNonNull(key1),
+        value1,
+        requireNonNull(key2),
+        value2,
+        requireNonNull(key3),
+        value3,
+        null,
+        null);
+  }
+
+  /**
+   * Creates a performance log record with meta data.
+   *
+   * @param operation the name of operation the is was performed
+   * @param durationMs the execution time in milliseconds
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   * @param key4 forth meta data key
+   * @param value4 forth meta data value
+   * @return the performance log record
+   */
+  public static PerformanceLogRecord create(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3,
+      String key4,
+      @Nullable Object value4) {
+    return new AutoValue_PerformanceLogRecord(
+        operation,
+        durationMs,
+        requireNonNull(key1),
+        value1,
+        requireNonNull(key2),
+        value2,
+        requireNonNull(key3),
+        value3,
+        requireNonNull(key4),
+        value4);
+  }
+
+  public abstract String operation();
+
+  public abstract long durationMs();
+
+  @Nullable
+  public abstract String key1();
+
+  @Nullable
+  public abstract Object value1();
+
+  @Nullable
+  public abstract String key2();
+
+  @Nullable
+  public abstract Object value2();
+
+  @Nullable
+  public abstract String key3();
+
+  @Nullable
+  public abstract Object value3();
+
+  @Nullable
+  public abstract String key4();
+
+  @Nullable
+  public abstract Object value4();
+
+  void writeTo(PerformanceLogger performanceLogger) {
+    if (key4() != null) {
+      requireNonNull(key1());
+      requireNonNull(key2());
+      requireNonNull(key3());
+      performanceLogger.log(
+          operation(),
+          durationMs(),
+          key1(),
+          value1(),
+          key2(),
+          value2(),
+          key3(),
+          value3(),
+          key4(),
+          value4());
+    } else if (key3() != null) {
+      requireNonNull(key1());
+      requireNonNull(key2());
+      performanceLogger.log(
+          operation(), durationMs(), key1(), value1(), key2(), value2(), key3(), value3());
+    } else if (key2() != null) {
+      requireNonNull(key1());
+      performanceLogger.log(operation(), durationMs(), key1(), value1(), key2(), value2());
+    } else if (key1() != null) {
+      performanceLogger.log(operation(), durationMs(), key1(), value1());
+    } else {
+      performanceLogger.log(operation(), durationMs());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
new file mode 100644
index 0000000..3e33a3a
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Extension point for logging performance records.
+ *
+ * <p>This extension point is invoked for all operations for which the execution time is measured.
+ * The invocation of the extension point does not happen immediately, but only at the end of a
+ * request (REST call, SSH call, git push). Implementors can write the execution times into a
+ * performance log for further analysis.
+ *
+ * <p>For optimal performance implementors should overwrite the default <code>log</code> methods to
+ * avoid unneeded instantiation of Map objects.
+ */
+@ExtensionPoint
+public interface PerformanceLogger {
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   */
+  default void log(String operation, long durationMs) {
+    log(operation, durationMs, ImmutableMap.of());
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param key meta data key
+   * @param value meta data value
+   */
+  default void log(String operation, long durationMs, String key, @Nullable Object value) {
+    log(operation, durationMs, ImmutableMap.of(key, Optional.ofNullable(value)));
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   */
+  default void log(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2) {
+    log(
+        operation,
+        durationMs,
+        ImmutableMap.of(key1, Optional.ofNullable(value1), key2, Optional.ofNullable(value2)));
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   */
+  default void log(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3) {
+    log(
+        operation,
+        durationMs,
+        ImmutableMap.of(
+            key1,
+            Optional.ofNullable(value1),
+            key2,
+            Optional.ofNullable(value2),
+            key3,
+            Optional.ofNullable(value3)));
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   * @param key4 fourth meta data key
+   * @param value4 fourth meta data value
+   */
+  default void log(
+      String operation,
+      long durationMs,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3,
+      String key4,
+      @Nullable Object value4) {
+    log(
+        operation,
+        durationMs,
+        ImmutableMap.of(
+            key1,
+            Optional.ofNullable(value1),
+            key2,
+            Optional.ofNullable(value2),
+            key3,
+            Optional.ofNullable(value3),
+            key4,
+            Optional.ofNullable(value4)));
+  }
+
+  /**
+   * Record the execution time of an operation in a performance log.
+   *
+   * <p>For small numbers of meta data entries the instantiation of a map should avoided by using
+   * one of the <code>log</code> methods that allows to pass in meta data entries directly.
+   *
+   * @param operation operation that was performed
+   * @param durationMs time that the execution of the operation took (in milliseconds)
+   * @param metaData key-value map with meta data
+   */
+  void log(String operation, long durationMs, Map<String, Optional<Object>> metaData);
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 5c0406d..b018da4 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -155,72 +155,116 @@
   /**
    * Opens a new timer that logs the time for an operation if request tracing is enabled.
    *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param message the message
+   * @param operation the name of operation the is being performed
    * @return the trace timer
    */
-  public static TraceTimer newTimer(String message) {
-    return new TraceTimer(message);
+  public static TraceTimer newTimer(String operation) {
+    return new TraceTimer(requireNonNull(operation, "operation is required"));
   }
 
   /**
    * Opens a new timer that logs the time for an operation if request tracing is enabled.
    *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg argument for the message
+   * @param operation the name of operation the is being performed
+   * @param key meta data key
+   * @param value meta data value
    * @return the trace timer
    */
-  public static TraceTimer newTimer(String format, Object arg) {
-    return new TraceTimer(format, arg);
+  public static TraceTimer newTimer(String operation, String key, @Nullable Object value) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(key, "key is required"),
+        value);
   }
 
   /**
    * Opens a new timer that logs the time for an operation if request tracing is enabled.
    *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @return the trace timer
-   */
-  public static TraceTimer newTimer(String format, Object arg1, Object arg2) {
-    return new TraceTimer(format, arg1, arg2);
-  }
-
-  /**
-   * Opens a new timer that logs the time for an operation if request tracing is enabled.
-   *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @param arg3 third argument for the message
-   * @return the trace timer
-   */
-  public static TraceTimer newTimer(String format, Object arg1, Object arg2, Object arg3) {
-    return new TraceTimer(format, arg1, arg2, arg3);
-  }
-
-  /**
-   * Opens a new timer that logs the time for an operation if request tracing is enabled.
-   *
-   * <p>If request tracing is not enabled this is a no-op.
-   *
-   * @param format the message format string
-   * @param arg1 first argument for the message
-   * @param arg2 second argument for the message
-   * @param arg3 third argument for the message
-   * @param arg4 fourth argument for the message
+   * @param operation the name of operation the is being performed
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
    * @return the trace timer
    */
   public static TraceTimer newTimer(
-      String format, Object arg1, Object arg2, Object arg3, Object arg4) {
-    return new TraceTimer(format, arg1, arg2, arg3, arg4);
+      String operation,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(key1, "key1 is required"),
+        value1,
+        requireNonNull(key2, "key2 is required"),
+        value2);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * @param operation the name of operation the is being performed
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(
+      String operation,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(key1, "key1 is required"),
+        value1,
+        requireNonNull(key2, "key2 is required"),
+        value2,
+        requireNonNull(key3, "key3 is required"),
+        value3);
+  }
+
+  /**
+   * Opens a new timer that logs the time for an operation if request tracing is enabled.
+   *
+   * @param operation the name of operation the is being performed
+   * @param key1 first meta data key
+   * @param value1 first meta data value
+   * @param key2 second meta data key
+   * @param value2 second meta data value
+   * @param key3 third meta data key
+   * @param value3 third meta data value
+   * @param key4 fourth meta data key
+   * @param value4 fourth meta data value
+   * @return the trace timer
+   */
+  public static TraceTimer newTimer(
+      String operation,
+      String key1,
+      @Nullable Object value1,
+      String key2,
+      @Nullable Object value2,
+      String key3,
+      @Nullable Object value3,
+      String key4,
+      @Nullable Object value4) {
+    return new TraceTimer(
+        requireNonNull(operation, "operation is required"),
+        requireNonNull(key1, "key1 is required"),
+        value1,
+        requireNonNull(key2, "key2 is required"),
+        value2,
+        requireNonNull(key3, "key3 is required"),
+        value3,
+        requireNonNull(key4, "key4 is required"),
+        value4);
   }
 
   public static class TraceTimer implements AutoCloseable {
@@ -229,31 +273,86 @@
     private final Consumer<Long> logFn;
     private final Stopwatch stopwatch;
 
-    private TraceTimer(String message) {
-      this(elapsedMs -> logger.atFine().log(message + " (%d ms)", elapsedMs));
-    }
-
-    private TraceTimer(String format, @Nullable Object arg) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg, elapsedMs));
-    }
-
-    private TraceTimer(String format, @Nullable Object arg1, @Nullable Object arg2) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, elapsedMs));
-    }
-
-    private TraceTimer(
-        String format, @Nullable Object arg1, @Nullable Object arg2, @Nullable Object arg3) {
-      this(elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, arg3, elapsedMs));
-    }
-
-    private TraceTimer(
-        String format,
-        @Nullable Object arg1,
-        @Nullable Object arg2,
-        @Nullable Object arg3,
-        @Nullable Object arg4) {
+    private TraceTimer(String operation) {
       this(
-          elapsedMs -> logger.atFine().log(format + " (%d ms)", arg1, arg2, arg3, arg4, elapsedMs));
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
+            logger.atFine().log("%s (%d ms)", operation, elapsedMs);
+          });
+    }
+
+    private TraceTimer(String operation, String key, @Nullable Object value) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () -> PerformanceLogRecord.create(operation, elapsedMs, key, value));
+            logger.atFine().log("%s (%s=%s) (%d ms)", operation, key, value, elapsedMs);
+          });
+    }
+
+    private TraceTimer(
+        String operation,
+        String key1,
+        @Nullable Object value1,
+        String key2,
+        @Nullable Object value2) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () ->
+                        PerformanceLogRecord.create(
+                            operation, elapsedMs, key1, value1, key2, value2));
+            logger.atFine().log(
+                "%s (%s=%s, %s=%s) (%d ms)", operation, key1, value1, key2, value2, elapsedMs);
+          });
+    }
+
+    private TraceTimer(
+        String operation,
+        String key1,
+        @Nullable Object value1,
+        String key2,
+        @Nullable Object value2,
+        String key3,
+        @Nullable Object value3) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () ->
+                        PerformanceLogRecord.create(
+                            operation, elapsedMs, key1, value1, key2, value2, key3, value3));
+            logger.atFine().log(
+                "%s (%s=%s, %s=%s, %s=%s) (%d ms)",
+                operation, key1, value1, key2, value2, key3, value3, elapsedMs);
+          });
+    }
+
+    private TraceTimer(
+        String operation,
+        String key1,
+        @Nullable Object value1,
+        String key2,
+        @Nullable Object value2,
+        String key3,
+        @Nullable Object value3,
+        String key4,
+        @Nullable Object value4) {
+      this(
+          elapsedMs -> {
+            LoggingContext.getInstance()
+                .addPerformanceLogRecord(
+                    () ->
+                        PerformanceLogRecord.create(
+                            operation, elapsedMs, key1, value1, key2, value2, key3, value3, key4,
+                            value4));
+            logger.atFine().log(
+                "%s (%s=%s, %s=%s, %s=%s, %s=%s) (%d ms)",
+                operation, key1, value1, key2, value2, key3, value3, key4, value4, elapsedMs);
+          });
     }
 
     private TraceTimer(Consumer<Long> logFn) {
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 3542187..3fc634d 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -293,7 +293,8 @@
 
     @Override
     public ProjectState load(String projectName) throws Exception {
-      try (TraceTimer timer = TraceContext.newTimer("Loading project %s", projectName)) {
+      try (TraceTimer timer =
+          TraceContext.newTimer("Loading project", "projectName", projectName)) {
         long now = clock.read();
         Project.NameKey key = Project.nameKey(projectName);
         try (Repository git = mgr.openRepository(key)) {
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 9828957..6c08c1e 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,14 +14,20 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.logging.PerformanceLogContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
 import org.apache.sshd.server.Environment;
 import org.kohsuke.args4j.Option;
 
 public abstract class SshCommand extends BaseCommand {
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
   @Option(name = "--trace", usage = "enable request tracing")
   private boolean trace;
 
@@ -38,7 +44,9 @@
           parseCommandLine();
           stdout = toPrintWriter(out);
           stderr = toPrintWriter(err);
-          try (TraceContext traceContext = enableTracing()) {
+          try (TraceContext traceContext = enableTracing();
+              PerformanceLogContext performanceLogContext =
+                  new PerformanceLogContext(performanceLoggers)) {
             SshCommand.this.run();
           } finally {
             stdout.flush();
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index fb0b8f6..94e7f1b 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -106,7 +106,8 @@
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (TraceTimer timer =
-          TraceContext.newTimer("Loading SSH keys for account with username %s", username)) {
+          TraceContext.newTimer(
+              "Loading SSH keys for account with username", "username", username)) {
         Optional<ExternalId> user =
             externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
         if (!user.isPresent()) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index b30dc41..0251a74 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -17,7 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.apache.http.HttpStatus.SC_CREATED;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Expect;
@@ -34,12 +36,16 @@
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.logging.LoggingContext;
+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.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import org.apache.http.message.BasicHeader;
@@ -53,12 +59,15 @@
 
   @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
   @Inject private WorkQueue workQueue;
 
   private TraceValidatingProjectCreationValidationListener projectCreationListener;
   private RegistrationHandle projectCreationListenerRegistrationHandle;
   private TraceValidatingCommitValidationListener commitValidationListener;
   private RegistrationHandle commitValidationRegistrationHandle;
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
 
   @Before
   public void setup() {
@@ -68,12 +77,15 @@
     commitValidationListener = new TraceValidatingCommitValidationListener();
     commitValidationRegistrationHandle =
         commitValidationListeners.add("gerrit", commitValidationListener);
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
   }
 
   @After
   public void cleanup() {
     projectCreationListenerRegistrationHandle.remove();
     commitValidationRegistrationHandle.remove();
+    performanceLoggerRegistrationHandle.remove();
   }
 
   @Test
@@ -263,6 +275,25 @@
     assertForceLogging(false);
   }
 
+  @Test
+  public void performanceLoggingForRestCall() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new10");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+
+    // This assertion assumes that the server invokes the PerformanceLogger plugins before it sends
+    // the response to the client. If this assertion gets flaky it's likely that this got changed on
+    // server-side.
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
+  @Test
+  public void performanceLoggingForPush() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/heads/master");
+    r.assertOkStatus();
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
   private void assertForceLogging(boolean expected) {
     assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
         .isEqualTo(expected);
@@ -296,4 +327,28 @@
       return ImmutableList.of();
     }
   }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Map<String, Optional<Object>> metaData) {
+      logEntries.add(PerformanceLogEntry.create(operation, metaData));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Map<String, Optional<Object>> metaData) {
+      return new AutoValue_TraceIT_PerformanceLogEntry(operation, ImmutableMap.copyOf(metaData));
+    }
+
+    abstract String operation();
+
+    abstract ImmutableMap<String, Object> metaData();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index 9c1e23d..5dd07c0 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -16,17 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.logging.LoggingContext;
+import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.project.CreateProjectArgs;
 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.List;
+import java.util.Map;
+import java.util.Optional;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -34,20 +42,26 @@
 @UseSsh
 public class SshTraceIT extends AbstractDaemonTest {
   @Inject private DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
 
   private TraceValidatingProjectCreationValidationListener projectCreationListener;
   private RegistrationHandle projectCreationListenerRegistrationHandle;
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
 
   @Before
   public void setup() {
     projectCreationListener = new TraceValidatingProjectCreationValidationListener();
     projectCreationListenerRegistrationHandle =
         projectCreationValidationListeners.add("gerrit", projectCreationListener);
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
   }
 
   @After
   public void cleanup() {
     projectCreationListenerRegistrationHandle.remove();
+    performanceLoggerRegistrationHandle.remove();
   }
 
   @Test
@@ -85,10 +99,17 @@
 
   @Test
   public void sshCallWithTraceIdAndWithoutTraceFails() throws Exception {
-    adminSshSession.exec("gerrit create-project --trace-id issue/123 new3");
+    adminSshSession.exec("gerrit create-project --trace-id issue/123 new4");
     adminSshSession.assertFailure("A trace ID can only be set if --trace was specified.");
   }
 
+  @Test
+  public void performanceLoggingForSshCall() throws Exception {
+    adminSshSession.exec("gerrit create-project new5");
+    adminSshSession.assertSuccess();
+    assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+  }
+
   private static class TraceValidatingProjectCreationValidationListener
       implements ProjectCreationValidationListener {
     String traceId;
@@ -103,4 +124,28 @@
       this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
     }
   }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Map<String, Optional<Object>> metaData) {
+      logEntries.add(PerformanceLogEntry.create(operation, metaData));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Map<String, Optional<Object>> metaData) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, ImmutableMap.copyOf(metaData));
+    }
+
+    abstract String operation();
+
+    abstract ImmutableMap<String, Object> metaData();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 5117c01..c495cd8 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -17,25 +17,71 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.truth.Expect;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.Map;
+import java.util.Optional;
 import java.util.SortedMap;
 import java.util.SortedSet;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
 public class LoggingContextAwareExecutorServiceTest {
   @Rule public final Expect expect = Expect.create();
 
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  private PerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger =
+        new PerformanceLogger() {
+          @Override
+          public void log(
+              String operation, long durationMs, Map<String, Optional<Object>> metaData) {
+            // do nothing
+          }
+        };
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+  }
+
   @Test
   public void loggingContextPropagationToBackgroundThread() throws Exception {
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
-    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar");
+        PerformanceLogContext performanceLogContext =
+            new PerformanceLogContext(performanceLoggers)) {
+      // Create a performance log record.
+      TraceContext.newTimer("test").close();
+
       SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
 
       ExecutorService executor =
           new LoggingContextAwareExecutorService(Executors.newFixedThreadPool(1));
@@ -51,17 +97,32 @@
                 expect
                     .that(LoggingContext.getInstance().shouldForceLogging(null, null, false))
                     .isTrue();
+                expect.that(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+                // Create another performance log record. We expect this to be visible in the outer
+                // thread.
+                TraceContext.newTimer("test2").close();
+                expect.that(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
               })
           .get();
 
-      // Verify that tags and force logging flag in the outer thread are still set.
+      // Verify that logging context values in the outer thread are still set.
       tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      // The performance log record that was added in the inner thread is available in addition to
+      // the performance log record that was created in the outer thread.
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
     }
+
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
   }
 
   private void assertForceLogging(boolean expected) {
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
new file mode 100644
index 0000000..4d0b1f0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -0,0 +1,467 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.logging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PerformanceLogContextTest {
+  @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+
+  // In this test setup this gets the DisabledMetricMaker injected. This means it doesn't record any
+  // metric, but performance log records are still created.
+  @Inject private MetricMaker metricMaker;
+
+  private TestPerformanceLogger testPerformanceLogger;
+  private RegistrationHandle performanceLoggerRegistrationHandle;
+
+  @Before
+  public void setup() {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+
+    testPerformanceLogger = new TestPerformanceLogger();
+    performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+  }
+
+  @After
+  public void cleanup() {
+    performanceLoggerRegistrationHandle.remove();
+
+    LoggingContext.getInstance().clearPerformanceLogEntries();
+    LoggingContext.getInstance().performanceLogging(false);
+  }
+
+  @Test
+  public void traceTimersInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", "foo", "bar").close();
+      TraceContext.newTimer("test3", "foo1", "bar1", "foo2", "bar2").close();
+      TraceContext.newTimer("test4", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3").close();
+      TraceContext.newTimer("test5", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3", "foo4", "bar4")
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(5);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1", ImmutableMap.of()),
+            PerformanceLogEntry.create("test2", ImmutableMap.of("foo", Optional.of("bar"))),
+            PerformanceLogEntry.create(
+                "test3", ImmutableMap.of("foo1", Optional.of("bar1"), "foo2", Optional.of("bar2"))),
+            PerformanceLogEntry.create(
+                "test4",
+                ImmutableMap.of(
+                    "foo1",
+                    Optional.of("bar1"),
+                    "foo2",
+                    Optional.of("bar2"),
+                    "foo3",
+                    Optional.of("bar3"))),
+            PerformanceLogEntry.create(
+                "test5",
+                ImmutableMap.of(
+                    "foo1",
+                    Optional.of("bar1"),
+                    "foo2",
+                    Optional.of("bar2"),
+                    "foo3",
+                    Optional.of("bar3"),
+                    "foo4",
+                    Optional.of("bar4"))))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void traceTimersInsidePerformanceLogContextCreatePerformanceLogNullValuesAllowed() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", "foo", null).close();
+      TraceContext.newTimer("test3", "foo1", null, "foo2", null).close();
+      TraceContext.newTimer("test4", "foo1", null, "foo2", null, "foo3", null).close();
+      TraceContext.newTimer("test5", "foo1", null, "foo2", null, "foo3", null, "foo4", null)
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(5);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1", ImmutableMap.of()),
+            PerformanceLogEntry.create("test2", ImmutableMap.of("foo", Optional.empty())),
+            PerformanceLogEntry.create(
+                "test3", ImmutableMap.of("foo1", Optional.empty(), "foo2", Optional.empty())),
+            PerformanceLogEntry.create(
+                "test4",
+                ImmutableMap.of(
+                    "foo1", Optional.empty(), "foo2", Optional.empty(), "foo3", Optional.empty())),
+            PerformanceLogEntry.create(
+                "test5",
+                ImmutableMap.of(
+                    "foo1",
+                    Optional.empty(),
+                    "foo2",
+                    Optional.empty(),
+                    "foo3",
+                    Optional.empty(),
+                    "foo4",
+                    Optional.empty())))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void traceTimersOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    TraceContext.newTimer("test1").close();
+    TraceContext.newTimer("test2", "foo", "bar").close();
+    TraceContext.newTimer("test3", "foo1", "bar1", "foo2", "bar2").close();
+    TraceContext.newTimer("test4", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3").close();
+    TraceContext.newTimer("test5", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3", "foo4", "bar4")
+        .close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      TraceContext.newTimer("test1").close();
+      TraceContext.newTimer("test2", "foo", "bar").close();
+      TraceContext.newTimer("test3", "foo1", "bar1", "foo2", "bar2").close();
+      TraceContext.newTimer("test4", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3").close();
+      TraceContext.newTimer("test5", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3", "foo4", "bar4")
+          .close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<String> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"));
+      timer1.start("value1").close();
+
+      Timer2<String, String> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"));
+      timer2.start("value1", "value2").close();
+
+      Timer3<String, String, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"),
+              Field.ofString("baz"));
+      timer3.start("value1", "value2", "value3").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(4);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create("test1/latency", ImmutableMap.of()),
+            PerformanceLogEntry.create(
+                "test2/latency", ImmutableMap.of("field1", Optional.of("value1"))),
+            PerformanceLogEntry.create(
+                "test3/latency",
+                ImmutableMap.of("field1", Optional.of("value1"), "field2", Optional.of("value2"))),
+            PerformanceLogEntry.create(
+                "test4/latency",
+                ImmutableMap.of(
+                    "field1",
+                    Optional.of("value1"),
+                    "field2",
+                    Optional.of("value2"),
+                    "field3",
+                    Optional.of("value3"))))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsInsidePerformanceLogContextCreatePerformanceLogNullValuesAllowed() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      Timer1<String> timer1 =
+          metricMaker.newTimer(
+              "test1/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"));
+      timer1.start(null).close();
+
+      Timer2<String, String> timer2 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"));
+      timer2.start(null, null).close();
+
+      Timer3<String, String, String> timer3 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"),
+              Field.ofString("baz"));
+      timer3.start(null, null, null).close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(3);
+    }
+
+    assertThat(testPerformanceLogger.logEntries())
+        .containsExactly(
+            PerformanceLogEntry.create(
+                "test1/latency", ImmutableMap.of("field1", Optional.empty())),
+            PerformanceLogEntry.create(
+                "test2/latency",
+                ImmutableMap.of("field1", Optional.empty(), "field2", Optional.empty())),
+            PerformanceLogEntry.create(
+                "test3/latency",
+                ImmutableMap.of(
+                    "field1",
+                    Optional.empty(),
+                    "field2",
+                    Optional.empty(),
+                    "field3",
+                    Optional.empty())))
+        .inOrder();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void timerMetricsOutsidePerformanceLogContextDoNotCreatePerformanceLog() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    Timer0 timer0 =
+        metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+    timer0.start().close();
+
+    Timer1<String> timer1 =
+        metricMaker.newTimer(
+            "test2/latency", new Description("Latency metric for testing"), Field.ofString("foo"));
+    timer1.start("value1").close();
+
+    Timer2<String, String> timer2 =
+        metricMaker.newTimer(
+            "test3/latency",
+            new Description("Latency metric for testing"),
+            Field.ofString("foo"),
+            Field.ofString("bar"));
+    timer2.start("value1", "value2").close();
+
+    Timer3<String, String, String> timer3 =
+        metricMaker.newTimer(
+            "test4/latency",
+            new Description("Latency metric for testing"),
+            Field.ofString("foo"),
+            Field.ofString("bar"),
+            Field.ofString("baz"));
+    timer3.start("value1", "value2", "value3").close();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+  }
+
+  @Test
+  public void
+      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+    // Remove test performance logger so that there are no registered performance loggers.
+    performanceLoggerRegistrationHandle.remove();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+
+      Timer0 timer0 =
+          metricMaker.newTimer("test1/latency", new Description("Latency metric for testing"));
+      timer0.start().close();
+
+      Timer1<String> timer1 =
+          metricMaker.newTimer(
+              "test2/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"));
+      timer1.start("value1").close();
+
+      Timer2<String, String> timer2 =
+          metricMaker.newTimer(
+              "test3/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"));
+      timer2.start("value1", "value2").close();
+
+      Timer3<String, String, String> timer3 =
+          metricMaker.newTimer(
+              "test4/latency",
+              new Description("Latency metric for testing"),
+              Field.ofString("foo"),
+              Field.ofString("bar"),
+              Field.ofString("baz"));
+      timer3.start("value1", "value2", "value3").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+    }
+
+    assertThat(testPerformanceLogger.logEntries()).isEmpty();
+
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  @Test
+  public void nestingPerformanceLogContextsIsPossible() {
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+
+    try (PerformanceLogContext traceContext1 = new PerformanceLogContext(performanceLoggers)) {
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+      TraceContext.newTimer("test1").close();
+
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+
+      try (PerformanceLogContext traceContext2 = new PerformanceLogContext(performanceLoggers)) {
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+        assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+
+        TraceContext.newTimer("test2").close();
+        TraceContext.newTimer("test3").close();
+
+        assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2);
+      }
+
+      assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue();
+      assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(1);
+    }
+    assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
+    assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
+  }
+
+  private static class TestPerformanceLogger implements PerformanceLogger {
+    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+
+    @Override
+    public void log(String operation, long durationMs, Map<String, Optional<Object>> metaData) {
+      logEntries.add(PerformanceLogEntry.create(operation, metaData));
+    }
+
+    ImmutableList<PerformanceLogEntry> logEntries() {
+      return ImmutableList.copyOf(logEntries);
+    }
+  }
+
+  @AutoValue
+  abstract static class PerformanceLogEntry {
+    static PerformanceLogEntry create(String operation, Map<String, Optional<Object>> metaData) {
+      return new AutoValue_PerformanceLogContextTest_PerformanceLogEntry(
+          operation, ImmutableMap.copyOf(metaData));
+    }
+
+    abstract String operation();
+
+    abstract ImmutableMap<String, Object> metaData();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 044d237..fedbe8b 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -236,6 +237,63 @@
     }
   }
 
+  @Test
+  public void operationForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null));
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer(null, "foo", "bar"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer(null, "foo1", "bar1", "foo2", "bar2"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer(null, "foo1", "bar1", "foo2", "bar2", "foo3", "bar3"));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                null, "foo1", "bar1", "foo2", "bar2", "foo3", "bar3", "foo4", "bar4"));
+  }
+
+  @Test
+  public void keysForTraceTimerCannotBeNull() throws Exception {
+    assertThrows(NullPointerException.class, () -> TraceContext.newTimer("test", null, "bar"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer("test", null, "bar1", "foo2", "bar2"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer("test", "foo1", "bar1", null, "bar2"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer("test", null, "bar1", "foo2", "bar2", "foo3", "bar3"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer("test", "foo1", "bar1", null, "bar2", "foo3", "bar3"));
+    assertThrows(
+        NullPointerException.class,
+        () -> TraceContext.newTimer("test", "foo1", "bar1", "foo2", "bar2", null, "bar3"));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                "test", null, "bar1", "foo2", "bar2", "foo3", "bar3", "foo4", "bar4"));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                "test", "foo1", "bar1", null, "bar2", "foo3", "bar3", "foo4", "bar4"));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                "test", "foo1", "bar1", "foo2", "bar2", null, "bar3", "foo4", "bar4"));
+    assertThrows(
+        NullPointerException.class,
+        () ->
+            TraceContext.newTimer(
+                "test", "foo1", "bar1", "foo2", "bar2", "foo3", "bar3", null, "bar4"));
+  }
+
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
     SortedMap<String, SortedSet<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();