Merge "RevisionDiffIT: Don't test unrelated aspects"
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index e518d26..1b946cd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -169,7 +169,7 @@
 
   @Override
   public void setValue(AccessSection value) {
-    Collections.sort(value.getPermissions());
+    sortPermissions(value);
 
     this.value = value;
     this.readOnly = !editing || !(projectAccess.isOwnerOf(value) || projectAccess.canUpload());
@@ -204,6 +204,12 @@
     }
   }
 
+  private void sortPermissions(AccessSection accessSection) {
+    List<Permission> permissionList = new ArrayList<>(accessSection.getPermissions());
+    Collections.sort(permissionList);
+    accessSection.setPermissions(permissionList);
+  }
+
   void setEditing(boolean editing) {
     this.editing = editing;
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 819012f..abb28ce 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1077,7 +1077,7 @@
       ProjectConfig config = ProjectConfig.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
-      p.getRules().clear();
+      p.clearRules();
       config.commit(md);
       projectCache.evict(config.getProject());
     }
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index b525f4d..f658066 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -34,11 +34,12 @@
     super(refPattern);
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<Permission> getPermissions() {
     if (permissions == null) {
-      permissions = new ArrayList<>();
+      return new ArrayList<>();
     }
-    return permissions;
+    return new ArrayList<>(permissions);
   }
 
   public void setPermissions(List<Permission> list) {
@@ -59,13 +60,19 @@
 
   @Nullable
   public Permission getPermission(String name, boolean create) {
-    for (Permission p : getPermissions()) {
-      if (p.getName().equalsIgnoreCase(name)) {
-        return p;
+    if (permissions != null) {
+      for (Permission p : permissions) {
+        if (p.getName().equalsIgnoreCase(name)) {
+          return p;
+        }
       }
     }
 
     if (create) {
+      if (permissions == null) {
+        permissions = new ArrayList<>();
+      }
+
       Permission p = new Permission(name);
       permissions.add(p);
       return p;
@@ -75,7 +82,10 @@
   }
 
   public void addPermission(Permission permission) {
-    List<Permission> permissions = getPermissions();
+    if (permissions == null) {
+      permissions = new ArrayList<>();
+    }
+
     for (Permission p : permissions) {
       if (p.getName().equalsIgnoreCase(permission.getName())) {
         throw new IllegalArgumentException();
@@ -133,4 +143,15 @@
     return new HashSet<>(getPermissions())
         .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
   }
+
+  @Override
+  public int hashCode() {
+    int hashCode = super.hashCode();
+    if (permissions != null) {
+      for (Permission permission : permissions) {
+        hashCode += permission.hashCode();
+      }
+    }
+    return hashCode;
+  }
 }
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index dff30d7..af23e39 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -155,13 +155,16 @@
     exclusiveGroup = newExclusiveGroup;
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<PermissionRule> getRules() {
-    initRules();
-    return rules;
+    if (rules == null) {
+      return new ArrayList<>();
+    }
+    return new ArrayList<>(rules);
   }
 
   public void setRules(List<PermissionRule> list) {
-    rules = list;
+    rules = new ArrayList<>(list);
   }
 
   public void add(PermissionRule rule) {
@@ -181,6 +184,12 @@
     }
   }
 
+  public void clearRules() {
+    if (rules != null) {
+      rules.clear();
+    }
+  }
+
   public PermissionRule getRule(GroupReference group) {
     return getRule(group, false);
   }
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 64b5bbb..3244232 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -34,6 +34,7 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import java.io.IOException;
@@ -324,6 +325,7 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 1196e47..a454c00 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -337,7 +337,7 @@
           }
         }
         if (viewData.view == null) {
-          viewData = view(rsrc, rc, req.getMethod(), path);
+          viewData = view(rc, req.getMethod(), path);
         }
       }
       checkRequiresCapability(viewData);
@@ -392,7 +392,7 @@
           }
         }
         if (viewData.view == null) {
-          viewData = view(rsrc, c, req.getMethod(), path);
+          viewData = view(c, req.getMethod(), path);
         }
         checkRequiresCapability(viewData);
       }
@@ -1109,10 +1109,7 @@
   }
 
   private ViewData view(
-      RestResource rsrc,
-      RestCollection<RestResource, RestResource> rc,
-      String method,
-      List<IdString> path)
+      RestCollection<RestResource, RestResource> rc, String method, List<IdString> path)
       throws AmbiguousViewException, RestApiException {
     DynamicMap<RestView<RestResource>> views = rc.views();
     final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
@@ -1136,10 +1133,8 @@
         return new ViewData(p.get(0), view);
       }
       view = views.get(p.get(0), "GET." + viewname);
-      if (view != null && view instanceof AcceptsPost && "POST".equals(method)) {
-        @SuppressWarnings("unchecked")
-        AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
-        return new ViewData(p.get(0), ap.post(rsrc));
+      if (view != null) {
+        return new ViewData(p.get(0), view);
       }
       throw new ResourceNotFoundException(projection);
     }
@@ -1150,10 +1145,8 @@
       return new ViewData(null, core);
     }
     core = views.get("gerrit", "GET." + p.get(0));
-    if (core instanceof AcceptsPost && "POST".equals(method)) {
-      @SuppressWarnings("unchecked")
-      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-      return new ViewData(null, ap.post(rsrc));
+    if (core != null) {
+      return new ViewData(null, core);
     }
 
     Map<String, RestView<RestResource>> r = new TreeMap<>();
diff --git a/java/com/google/gerrit/server/UsedAt.java b/java/com/google/gerrit/server/UsedAt.java
index 6cf5b67..b564157 100644
--- a/java/com/google/gerrit/server/UsedAt.java
+++ b/java/com/google/gerrit/server/UsedAt.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.common.annotations.GwtCompatible;
@@ -27,13 +28,16 @@
  * organisation using Gerrit.
  */
 @BindingAnnotation
-@Target({METHOD})
+@Target({METHOD, TYPE})
 @Retention(RUNTIME)
 @GwtCompatible
 public @interface UsedAt {
   /** Enumeration of projects that call a method that would otherwise be private. */
   enum Project {
-    GOOGLE
+    GOOGLE,
+    PLUGIN_DELETE_PROJECT,
+    PLUGIN_SERVICEUSER,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index fb42ed0..358a3a8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -70,6 +69,7 @@
 import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
+import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
 import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
@@ -153,7 +153,7 @@
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
-  private final PureRevert pureRevert;
+  private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
 
   @Inject
@@ -200,7 +200,7 @@
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
-      PureRevert pureRevert,
+      Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
@@ -245,7 +245,7 @@
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
-    this.pureRevert = pureRevert;
+    this.getPureRevertProvider = getPureRevertProvider;
     this.stars = stars;
     this.change = change;
   }
@@ -714,7 +714,9 @@
   @Override
   public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
     try {
-      return pureRevert.get(change.getNotes(), claimedOriginal);
+      GetPureRevert getPureRevert = getPureRevertProvider.get();
+      getPureRevert.setClaimedOriginal(claimedOriginal);
+      return getPureRevert.apply(change);
     } catch (Exception e) {
       throw asRestApiException("Cannot compute pure revert", e);
     }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index d7baee2..1365cb6 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -48,7 +48,6 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.h2.jdbc.JdbcSQLException;
 
 /**
  * Hybrid in-memory and database backed cache built on H2.
@@ -341,8 +340,10 @@
               b.put(keyType.get(r, 1));
             }
           }
-        } catch (JdbcSQLException e) {
-          if (e.getCause() instanceof InvalidClassException) {
+        } catch (Exception e) {
+          if (Throwables.getCausalChain(e)
+              .stream()
+              .anyMatch(InvalidClassException.class::isInstance)) {
             // If deserialization failed using default Java serialization, this means we are using
             // the old serialVersionUID-based invalidation strategy. In that case, authors are
             // most likely bumping serialVersionUID rather than using the new versioning in the
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 2c450a7..ddc9661 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -28,6 +28,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.List;
@@ -42,6 +43,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+@Singleton
 public class PureRevert {
   private final MergeUtil.Factory mergeUtilFactory;
   private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 04f4d6c..f49951f 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -49,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+@UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
   final GitRepositoryManager server;
   final ProjectCache projectCache;
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f1b6639..15fedd7 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.inject.Inject;
@@ -54,6 +55,7 @@
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
index 087a314a..30bd244 100644
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ b/java/com/google/gerrit/server/project/AccountsSection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.PermissionRule;
 import java.util.ArrayList;
 import java.util.List;
@@ -21,14 +22,14 @@
 public class AccountsSection {
   protected List<PermissionRule> sameGroupVisibility;
 
-  public List<PermissionRule> getSameGroupVisibility() {
+  public ImmutableList<PermissionRule> getSameGroupVisibility() {
     if (sameGroupVisibility == null) {
-      sameGroupVisibility = new ArrayList<>();
+      sameGroupVisibility = ImmutableList.of();
     }
-    return sameGroupVisibility;
+    return ImmutableList.copyOf(sameGroupVisibility);
   }
 
   public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = sameGroupVisibility;
+    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 1796c40..d42b652 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -737,7 +737,7 @@
     }
   }
 
-  private List<PermissionRule> loadPermissionRules(
+  private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc,
       String section,
       String subsection,
@@ -746,7 +746,7 @@
       boolean useRange) {
     Permission perm = new Permission(varName);
     loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return perm.getRules();
+    return ImmutableList.copyOf(perm.getRules());
   }
 
   private void loadPermissionRules(
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index e42e5d1..4bbd8fc 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -119,6 +120,7 @@
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public static String generate() {
     byte[] rand = new byte[LEN];
     rng.nextBytes(rand);
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
index 42675f6..75019af 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
@@ -30,13 +30,15 @@
 public class GetPureRevert implements RestReadView<ChangeResource> {
 
   private final PureRevert pureRevert;
+  @Nullable private String claimedOriginal;
 
   @Option(
       name = "--claimed-original",
       aliases = {"-o"},
       usage = "SHA1 (40 digit hex) of the original commit")
-  @Nullable
-  private String claimedOriginal;
+  public void setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+  }
 
   @Inject
   GetPureRevert(PureRevert pureRevert) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index b6fc010..b7be2a8 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -89,7 +89,10 @@
       throw new UnprocessableEntityException(input.assignee + " is not active");
     }
     try {
-      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+      rsrc.permissions()
+          .database(db)
+          .absentUser(assignee.getAccountId())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new AuthException("read not permitted for " + input.assignee);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 422c749..c5c8cf0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -115,7 +115,7 @@
           }
           p.add(r);
         }
-        accessSection.getPermissions().add(p);
+        accessSection.addPermission(p);
       }
       sections.add(accessSection);
     }
diff --git a/java/com/google/gerrit/server/schema/JdbcUtil.java b/java/com/google/gerrit/server/schema/JdbcUtil.java
index 2624923..dddf23a 100644
--- a/java/com/google/gerrit/server/schema/JdbcUtil.java
+++ b/java/com/google/gerrit/server/schema/JdbcUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.server.UsedAt;
+
 public class JdbcUtil {
 
   public static String hostname(String hostname) {
@@ -26,6 +28,7 @@
     return hostname;
   }
 
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
   public static String port(String port) {
     if (port != null && !port.isEmpty()) {
       return ":" + port;
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
index 48cc91e..61e9c92 100644
--- a/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.UsedAt;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
@@ -49,6 +50,7 @@
     this.versionNbr = guessVersion(getClass());
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public static int guessVersion(Class<?> c) {
     String n = c.getName();
     n = n.substring(n.lastIndexOf('_') + 1);
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index b362a36..80430c4 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -336,8 +336,7 @@
             c ->
                 cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
                     .getPermission(c, true)
-                    .getRules()
-                    .clear());
+                    .clearRules());
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
index a7f1329..59c0903 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
@@ -248,14 +248,23 @@
       ImmutableList.of(RestCall.get("/changes/%s/messages/%s"));
 
   /**
+   * Change edit REST endpoints that create an edit to be tested, each URL contains placeholders for
+   * the change identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_CREATE_ENDPOINTS =
+      ImmutableList.of(
+          // Create change edit by editing an existing file.
+          RestCall.put("/changes/%s/edit/%s"),
+
+          // Create change edit by deleting an existing file.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  /**
    * Change edit REST endpoints to be tested, each URL contains placeholders for the change
    * identifier and the change edit identifier.
    */
   private static final ImmutableList<RestCall> CHANGE_EDIT_ENDPOINTS =
       ImmutableList.of(
-          // Create change edit by deleting an existing file.
-          RestCall.delete("/changes/%s/edit/%s"),
-
           // Calls on existing change edit.
           RestCall.get("/changes/%s/edit/%s"),
           RestCall.put("/changes/%s/edit/%s"),
@@ -460,10 +469,21 @@
   }
 
   @Test
-  public void changeEditEndpoints() throws Exception {
+  public void changeEditCreateEndpoints() throws Exception {
     String changeId = createChange("Subject", FILENAME, "content").getChangeId();
 
-    // The change edit is created by the first REST call.
+    // Each of the REST calls creates the change edit newly.
+    execute(
+        CHANGE_EDIT_CREATE_ENDPOINTS,
+        () -> adminRestSession.delete("/changes/" + changeId + "/edit"),
+        changeId,
+        FILENAME);
+  }
+
+  @Test
+  public void changeEditEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    gApi.changes().id(changeId).edit().create();
     execute(CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
index 834dbfa..bcae987 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
@@ -25,16 +25,10 @@
 import java.io.File;
 import org.eclipse.jgit.lib.ReflogEntry;
 import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
 import org.junit.Test;
 
 @UseLocalDisk
 public class ReflogIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
   @Test
   public void guessRestApiInReflog() throws Exception {
     assume().that(notesMigration.disableChangeReviewDb()).isTrue();
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
new file mode 100644
index 0000000..ecdb3c8
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -0,0 +1,253 @@
+// 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class AccessSectionTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = new AccessSection(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    assertThat(accessSection.getPermissions()).isNotNull();
+    assertThat(accessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(new Permission(Permission.ABANDON), new Permission(Permission.ABANDON)));
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission abandonPermissionLowerCase =
+        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission abandonPermissionUpperCase =
+        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
+
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase));
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.getPermission("non-existing")).isNull();
+    assertThat(accessSection.getPermission("non-existing", false)).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
+        .isEqualTo(new Permission(Permission.SUBMIT));
+  }
+
+  @Test
+  public void addPermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(abandonPermission);
+    permissions.add(rebasePermission);
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    permissions.add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(new Permission(Permission.ABANDON));
+    permissions.add(new Permission(Permission.REBASE));
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void removePermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void mergeAccessSections() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
+    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
+    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
+
+    accessSection1.mergeFrom(accessSection2);
+    assertThat(accessSection1.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
+
+    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
+    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
+    assertThat(accessSection.equals(accessSectionOther)).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(accessSection.equals(accessSectionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
new file mode 100644
index 0000000..f76323f
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -0,0 +1,341 @@
+// 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.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission permission;
+
+  @Before
+  public void setup() {
+    this.permission = new Permission(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission("Code-Review").getLabel()).isNull();
+    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = new Permission(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRules()).isNotNull();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+    permission.setRules(ImmutableList.of(permissionRule3));
+    assertThat(permission.getRules()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(permissionRule1);
+    rules.add(permissionRule2);
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    rules.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2")));
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3")));
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference1)).isNull();
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+    assertThat(permission.getRule(groupReference, false)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule = new PermissionRule(groupReference);
+    permission.setRules(ImmutableList.of(permissionRule));
+    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
+  }
+
+  @Test
+  public void createMissingRuleOnGet() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+
+    assertThat(permission.getRule(groupReference, true))
+        .isEqualTo(new PermissionRule(groupReference));
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
+    assertThat(permission.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void mergePermissions() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+
+    Permission permission1 = new Permission("foo");
+    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permission2 = new Permission("bar");
+    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
+
+    permission1.mergeFrom(permission2);
+    assertThat(permission1.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permissionSameRulesOtherName = new Permission("bar");
+    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
+        ImmutableList.of(permissionRule1, permissionRule2));
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission permissionOther = new Permission(PERMISSION_NAME);
+    permissionOther.setRules(ImmutableList.of(permissionRule1));
+    assertThat(permission.equals(permissionOther)).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.equals(permissionOther)).isTrue();
+  }
+}
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
new file mode 100644
index 0000000..68000bc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
@@ -0,0 +1,75 @@
+<!--
+@license
+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.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.SafeTypes */
+  Gerrit.SafeTypes = {};
+
+  const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|\/|#)/i;
+
+  /**
+   * Wraps a string to be used as a URL. An error is thrown if the string cannot
+   * be considered safe.
+   * @constructor
+   * @param {string} url the unwrapped, potentially unsafe URL.
+   */
+  Gerrit.SafeTypes.SafeUrl = function(url) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  };
+
+  /**
+   * Get the string representation of the safe URL.
+   * @returns {string}
+   */
+  Gerrit.SafeTypes.SafeUrl.prototype.asString = function() {
+    return this._url;
+  };
+
+  Gerrit.SafeTypes.safeTypesBridge = function(value, type) {
+    // If the value is being bound to a URL, ensure the value is wrapped in the
+    // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+    // to surface the error.
+    if (type === 'URL') {
+      let safeValue = null;
+      if (value instanceof Gerrit.SafeTypes.SafeUrl) {
+        safeValue = value;
+      } else if (typeof value === 'string') {
+        safeValue = new Gerrit.SafeTypes.SafeUrl(value);
+      }
+      if (safeValue) {
+        return safeValue.asString();
+      }
+    }
+
+    // If the value is being bound to a string or a constant, then the string
+    // can be used as is.
+    if (type === 'STRING' || type === 'CONSTANT') {
+      return value;
+    }
+
+    // Otherwise fail.
+    throw new Error(`Refused to bind value as ${type}: ${value}`);
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
new file mode 100644
index 0000000..bc16b39
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<title>safe-types-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="safe-types-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <safe-types-element></safe-types-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      Polymer({
+        is: 'safe-types-element',
+        behaviors: [Gerrit.SafeTypes],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('SafeUrl accepts valid urls', () => {
+      function accepts(url) {
+        const safeUrl = new element.SafeUrl(url);
+        assert.isOk(safeUrl);
+        assert.equal(url, safeUrl.asString());
+      }
+      accepts('http://www.google.com/');
+      accepts('https://www.google.com/');
+      accepts('HtTpS://www.google.com/');
+      accepts('//www.google.com/');
+      accepts('/c/1234/file/path.html@45');
+      accepts('#hash-url');
+      accepts('mailto:name@example.com');
+    });
+
+    test('SafeUrl rejects invalid urls', () => {
+      function rejects(url) {
+        assert.throws(() => { new element.SafeUrl(url); });
+      }
+      rejects('javascript://alert("evil");');
+      rejects('ftp:example.com');
+      rejects('data:text/html,scary business');
+    });
+
+    suite('safeTypesBridge', () => {
+      function acceptsString(value, type) {
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
+            value);
+      }
+
+      function rejects(value, type) {
+        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
+      }
+
+      test('accepts valid URL strings', () => {
+        acceptsString('/foo/bar', 'URL');
+        acceptsString('#baz', 'URL');
+      });
+
+      test('rejects invalid URL strings', () => {
+        rejects('javascript://void();', 'URL');
+      });
+
+      test('accepts SafeUrl values', () => {
+        const url = '/abc/123';
+        const safeUrl = new element.SafeUrl(url);
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+      });
+
+      test('rejects non-string or non-SafeUrl types', () => {
+        rejects(3.1415926, 'URL');
+      });
+
+      test('accepts any binding to STRING or CONSTANT', () => {
+        acceptsString('foo/bar/baz', 'STRING');
+        acceptsString('lorem ipsum dolor', 'CONSTANT');
+      });
+
+      test('rejects all other types', () => {
+        rejects('foo', 'JAVASCRIPT');
+        rejects('foo', 'HTML');
+        rejects('foo', 'RESOURCE_URL');
+        rejects('foo', 'STYLE');
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index e601b93..3e886d5 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -20,7 +20,8 @@
   // Latency reporting constants.
   const TIMING = {
     TYPE: 'timing-report',
-    CATEGORY: 'UI Latency',
+    CATEGORY_UI_LATENCY: 'UI Latency',
+    CATEGORY_RPC: 'RPC Timing',
     // Reported events - alphabetize below.
     APP_STARTED: 'App Started',
     PAGE_LOADED: 'Page Loaded',
@@ -170,7 +171,16 @@
       report.apply(this, args);
     },
 
-    defaultReporter(type, category, eventName, eventValue) {
+    /**
+     * The default reporter reports events immediately.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    defaultReporter(type, category, eventName, eventValue, opt_noLog) {
       const detail = {
         type,
         category,
@@ -178,6 +188,7 @@
         value: eventValue,
       };
       document.dispatchEvent(new CustomEvent(type, {detail}));
+      if (opt_noLog) { return; }
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       } else {
@@ -186,7 +197,17 @@
       }
     },
 
-    cachingReporter(type, category, eventName, eventValue) {
+    /**
+     * The caching reporter will queue reports until plugins have loaded, and
+     * log events immediately if they're reported after plugins have loaded.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    cachingReporter(type, category, eventName, eventValue, opt_noLog) {
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       }
@@ -196,9 +217,9 @@
             this.reporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue);
+        this.reporter(type, category, eventName, eventValue, opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue]);
+        pending.push([type, category, eventName, eventValue, opt_noLog]);
       }
     },
 
@@ -208,8 +229,8 @@
     appStarted(hidden) {
       const startTime =
           new Date().getTime() - this.performanceTiming.navigationStart;
-      this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+          TIMING.APP_STARTED, startTime);
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
@@ -226,8 +247,8 @@
       } else {
         const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
-        this.reporter(
-            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+            TIMING.PAGE_LOADED, loadTime);
       }
     },
 
@@ -344,7 +365,8 @@
      * @param {number} time The time to report as an integer of milliseconds.
      */
     _reportTiming(name, time) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, Math.round(time));
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name,
+          Math.round(time));
     },
 
     /**
@@ -395,6 +417,16 @@
       return timer.reset();
     },
 
+    /**
+     * Log timing information for an RPC.
+     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+     * @param {number} elapsed The time elapsed of the RPC.
+     */
+    reportRpcTiming(anonymizedUrl, elapsed) {
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+          elapsed, true);
+    },
+
     reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index e78f49fb..8b85074 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -264,7 +264,7 @@
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42, undefined
         ));
       });
 
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index efcefe2..e7bd965 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -30,10 +30,12 @@
 
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
     reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
 
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 7acb680..b4aec99 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -99,6 +99,7 @@
       'page-error': '_handlePageError',
       'title-change': '_handleTitleChange',
       'location-change': '_handleLocationChange',
+      'rpc-log': '_handleRpcLog',
     },
 
     observers: [
@@ -332,5 +333,15 @@
       console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
       console.groupEnd();
     },
+
+    /**
+     * Intercept RPC log events emitted by REST API interfaces.
+     * Note: the REST API interface cannot use gr-reporting directly because
+     * that would create a cyclic dependency.
+     */
+    _handleRpcLog(e) {
+      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+          e.detail.elapsed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 5d68e01..fa1ad11 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -28,6 +28,15 @@
   Defs.patchRange;
 
   /**
+   * @typedef {{
+   *    url: string,
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   * }}
+   */
+  Defs.FetchRequest;
+
+  /**
    * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
    * - url is the URL for the request (excluding get params)
    * - errFn is a function to invoke when the request fails.
@@ -40,6 +49,8 @@
    *    cancelCondition: (function()|null|undefined),
    *    params: (Object|null|undefined),
    *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   *    reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.FetchJSONRequest;
@@ -53,6 +64,8 @@
    *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeFetchRequest;
@@ -78,6 +91,8 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedUrl: (string|undefined),
+   *   reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.SendRequest;
@@ -93,6 +108,8 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeSendRequest;
@@ -115,6 +132,9 @@
       'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
   const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
+  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+      '/revisions/*';
 
   Polymer({
     is: 'gr-rest-api-interface',
@@ -143,6 +163,12 @@
      * @event auth-error
      */
 
+    /**
+     * Fired after an RPC completes.
+     *
+     * @event rpc-log
+     */
+
     properties: {
       _cache: {
         type: Object,
@@ -182,15 +208,14 @@
     /**
      * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
      * with timing and logging.
-     * @param {string} url
-     * @param {Object=} opt_fetchOptions
+     * @param {Defs.FetchRequest} req
      */
-    _fetch(url, opt_fetchOptions) {
+    _fetch(req) {
       const start = Date.now();
-      const xhr = this._auth.fetch(url, opt_fetchOptions);
+      const xhr = this._auth.fetch(req.url, req.fetchOptions);
 
       // Log the call after it completes.
-      xhr.then(res => this._logCall(url, opt_fetchOptions, start, res.status));
+      xhr.then(res => this._logCall(req, start, res.status));
 
       // Return the XHR directly (without the log).
       return xhr;
@@ -200,18 +225,27 @@
      * Log information about a REST call. Because the elapsed time is determined
      * by this method, it should be called immediately after the request
      * finishes.
-     * @param {string} url
-     * @param {Object|undefined} fetchOptions
+     * @param {Defs.FetchRequest} req
      * @param {number} startTime the time that the request was started.
      * @param {number} status the HTTP status of the response. The status value
      *     is used here rather than the response object so there is no way this
      *     method can read the body stream.
      */
-    _logCall(url, fetchOptions, startTime, status) {
-      const method = (fetchOptions && fetchOptions.method) ?
-          fetchOptions.method : 'GET';
-      const elapsed = (Date.now() - startTime) + 'ms';
-      console.log(['HTTP', status, method, elapsed, url].join(' '));
+    _logCall(req, startTime, status) {
+      const method = (req.fetchOptions && req.fetchOptions.method) ?
+          req.fetchOptions.method : 'GET';
+      const elapsed = (Date.now() - startTime);
+      console.log([
+        'HTTP',
+        status,
+        method,
+        elapsed + 'ms',
+        req.anonymizedUrl || req.url,
+      ].join(' '));
+      if (req.anonymizedUrl) {
+        this.fire('rpc-log',
+            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+      }
     },
 
     /**
@@ -223,7 +257,12 @@
      */
     _fetchRawJSON(req) {
       const urlWithParams = this._urlWithParams(req.url, req.params);
-      return this._fetch(urlWithParams, req.fetchOptions).then(res => {
+      const fetchReq = {
+        url: urlWithParams,
+        fetchOptions: req.fetchOptions,
+        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+      };
+      return this._fetch(fetchReq).then(res => {
         if (req.cancelCondition && req.cancelCondition()) {
           res.body.cancel();
           return;
@@ -324,10 +363,16 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL({url: '/config/server/info'});
+        return this._fetchSharedCacheURL({
+          url: '/config/server/info',
+          reportUrlAsIs: true,
+        });
       }
 
-      return this._fetchJSON({url: '/config/server/info'});
+      return this._fetchJSON({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      });
     },
 
     getRepo(repo, opt_errFn) {
@@ -336,6 +381,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo),
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -345,6 +391,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo) + '/config',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -353,6 +400,7 @@
       // supports it.
       return this._fetchSharedCacheURL({
         url: '/access/?project=' + encodeURIComponent(repo),
+        anonymizedUrl: '/access/?project=*',
       });
     },
 
@@ -362,6 +410,7 @@
       return this._fetchSharedCacheURL({
         url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards?inherited',
       });
     },
 
@@ -374,6 +423,7 @@
         url: `/projects/${encodeName}/config`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -387,6 +437,7 @@
         url: `/projects/${encodeName}/gc`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/gc',
       });
     },
 
@@ -404,6 +455,7 @@
         url: `/projects/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -419,6 +471,7 @@
         url: `/groups/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*',
       });
     },
 
@@ -426,6 +479,7 @@
       return this._fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/detail',
       });
     },
 
@@ -445,6 +499,7 @@
         url: `/projects/${encodeName}/branches/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -464,6 +519,7 @@
         url: `/projects/${encodeName}/tags/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -484,6 +540,7 @@
         url: `/projects/${encodeName}/branches/${encodeBranch}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -504,6 +561,7 @@
         url: `/projects/${encodeName}/tags/${encodeTag}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -513,7 +571,11 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
+      const req = {
+        url: `/groups/?owned&q=${encodeName}`,
+        anonymizedUrl: '/groups/owned&q=*',
+      };
+      return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -522,12 +584,15 @@
       return this._fetchJSON({
         url: `/groups/${encodeName}/members/`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/members',
       });
     },
 
     getIncludedGroup(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._fetchJSON({url: `/groups/${encodeName}/groups/`});
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+        anonymizedUrl: '/groups/*/groups',
+      });
     },
 
     saveGroupName(groupId, name) {
@@ -536,6 +601,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
+        anonymizedUrl: '/groups/*/name',
       });
     },
 
@@ -545,6 +611,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
+        anonymizedUrl: '/groups/*/owner',
       });
     },
 
@@ -554,6 +621,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
+        anonymizedUrl: '/groups/*/description',
       });
     },
 
@@ -563,6 +631,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
+        anonymizedUrl: '/groups/*/options',
       });
     },
 
@@ -570,6 +639,7 @@
       return this._fetchSharedCacheURL({
         url: '/groups/' + group + '/log.audit',
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/log.audit',
       });
     },
 
@@ -580,6 +650,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         parseResponse: true,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -590,6 +661,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/groups/*',
       };
       return this._send(req).then(response => {
         if (response.ok) {
@@ -604,6 +676,7 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/members/${encodeMember}`,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -613,11 +686,15 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+        anonymizedUrl: '/groups/*/groups/*',
       });
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL({url: '/config/server/version'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/version',
+        reportUrlAsIs: true,
+      });
     },
 
     getDiffPreferences() {
@@ -625,6 +702,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.diff',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -655,6 +733,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.edit',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -696,6 +775,7 @@
         url: '/accounts/self/preferences',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -711,6 +791,7 @@
         url: '/accounts/self/preferences.diff',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -726,12 +807,14 @@
         url: '/accounts/self/preferences.edit',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     getAccount() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/detail',
+        reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
             this._cache['/accounts/self/detail'] = null;
@@ -741,7 +824,10 @@
     },
 
     getExternalIds() {
-      return this._fetchJSON({url: '/accounts/self/external.ids'});
+      return this._fetchJSON({
+        url: '/accounts/self/external.ids',
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountIdentity(id) {
@@ -750,6 +836,7 @@
         url: '/accounts/self/external.ids:delete',
         body: id,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -760,11 +847,15 @@
     getAccountDetails(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
+        anonymizedUrl: '/accounts/*/detail',
       });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/emails',
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -776,6 +867,7 @@
         method: 'PUT',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/account/self/emails/*',
       });
     },
 
@@ -788,6 +880,7 @@
         method: 'DELETE',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/email/*',
       });
     },
 
@@ -797,8 +890,13 @@
      */
     setPreferredAccountEmail(email, opt_errFn) {
       const encodedEmail = encodeURIComponent(email);
-      const url = `/accounts/self/emails/${encodedEmail}/preferred`;
-      return this._send({method: 'PUT', url, errFn: opt_errFn}).then(() => {
+      const req = {
+        method: 'PUT',
+        url: `/accounts/self/emails/${encodedEmail}/preferred`,
+        errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/emails/*/preferred',
+      };
+      return this._send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
         const cachedEmails = this._cache['/accounts/self/emails'];
@@ -840,6 +938,7 @@
         body: {name},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
@@ -856,6 +955,7 @@
         body: {username},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
@@ -872,6 +972,7 @@
         body: {status},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
@@ -880,15 +981,22 @@
     getAccountStatus(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
+        anonymizedUrl: '/accounts/*/status',
       });
     },
 
     getAccountGroups() {
-      return this._fetchJSON({url: '/accounts/self/groups'});
+      return this._fetchJSON({
+        url: '/accounts/self/groups',
+        reportUrlAsIs: true,
+      });
     },
 
     getAccountAgreements() {
-      return this._fetchJSON({url: '/accounts/self/agreements'});
+      return this._fetchJSON({
+        url: '/accounts/self/agreements',
+        reportUrlAsIs: true,
+      });
     },
 
     saveAccountAgreement(name) {
@@ -896,6 +1004,7 @@
         method: 'PUT',
         url: '/accounts/self/agreements',
         body: name,
+        reportUrlAsIs: true,
       });
     },
 
@@ -911,6 +1020,7 @@
       }
       return this._fetchSharedCacheURL({
         url: '/accounts/self/capabilities' + queryString,
+        anonymizedUrl: '/accounts/self/capabilities?q=*',
       });
     },
 
@@ -937,8 +1047,9 @@
         return;
       }
       this._credentialCheck.checking = true;
+      const req = {url: '/accounts/self/detail', reportUrlAsIs: true};
       // Skip the REST response cache.
-      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+      return this._fetchRawJSON(req).then(res => {
         if (!res) { return; }
         if (res.status === 403) {
           this.fire('auth-error');
@@ -958,21 +1069,24 @@
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/preferences',
+        reportUrlAsIs: true,
+      });
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
-              .then(res => {
-                if (this._isNarrowScreen()) {
-                  res.default_diff_view = DiffViewMode.UNIFIED;
-                } else {
-                  res.default_diff_view = res.diff_view;
-                }
-                return Promise.resolve(res);
-              });
+          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+          return this._fetchSharedCacheURL(req).then(res => {
+            if (this._isNarrowScreen()) {
+              res.default_diff_view = DiffViewMode.UNIFIED;
+            } else {
+              res.default_diff_view = res.diff_view;
+            }
+            return Promise.resolve(res);
+          });
         }
 
         return Promise.resolve({
@@ -988,6 +1102,7 @@
     getWatchedProjects() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/watched.projects',
+        reportUrlAsIs: true,
       });
     },
 
@@ -1002,6 +1117,7 @@
         body: projects,
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1015,6 +1131,7 @@
         url: '/accounts/self/watched.projects:delete',
         body: projects,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1079,7 +1196,12 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this._fetchJSON({url: '/changes/', params}).then(response => {
+      const req = {
+        url: '/changes/',
+        params,
+        reportUrlAsIs: true,
+      };
+      return this._fetchJSON(req).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -1173,6 +1295,7 @@
           cancelCondition: opt_cancelCondition,
           params: {O: params},
           fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + params,
         };
         return this._fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
@@ -1213,6 +1336,7 @@
         changeNum,
         endpoint: '/commit?links',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1233,6 +1357,7 @@
         endpoint: '/files',
         patchNum: patchRange.patchNum,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1242,10 +1367,16 @@
      */
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
+      let anonymizedEndpoint = endpoint;
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+        anonymizedEndpoint += '&base=*';
       }
-      return this._getChangeURLAndFetch({changeNum, endpoint});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        anonymizedEndpoint,
+      });
     },
 
     /**
@@ -1259,6 +1390,7 @@
         changeNum,
         endpoint: `/files?q=${encodeURIComponent(query)}`,
         patchNum,
+        anonymizedEndpoint: '/files?q=*',
       });
     },
 
@@ -1287,7 +1419,12 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      const req = {changeNum, endpoint: '/actions', patchNum};
+      const req = {
+        changeNum,
+        endpoint: '/actions',
+        patchNum,
+        reportEndpointAsIs: true,
+      };
       return this._getChangeURLAndFetch(req).then(revisionActions => {
         // The rebase button on change screen is always enabled.
         if (revisionActions.rebase) {
@@ -1312,6 +1449,7 @@
         endpoint: '/suggest_reviewers',
         errFn: opt_errFn,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1319,7 +1457,11 @@
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/in',
+        reportEndpointAsIs: true,
+      });
     },
 
     _computeFilter(filter) {
@@ -1345,6 +1487,7 @@
       return this._fetchSharedCacheURL({
         url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
             this._computeFilter(filter),
+        anonymizedUrl: '/groups/?*',
       });
     },
 
@@ -1362,6 +1505,7 @@
       return this._fetchSharedCacheURL({
         url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
             this._computeFilter(filter),
+        anonymizedUrl: '/projects/?*',
       });
     },
 
@@ -1372,6 +1516,7 @@
         method: 'PUT',
         url: `/projects/${encodeURIComponent(repo)}/HEAD`,
         body: {ref},
+        anonymizedUrl: '/projects/*/HEAD',
       });
     },
 
@@ -1391,7 +1536,11 @@
       const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches?*',
+      });
     },
 
     /**
@@ -1411,7 +1560,11 @@
           encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags',
+      });
     },
 
     /**
@@ -1426,7 +1579,11 @@
       const encodedFilter = this._computeFilter(filter);
       const n = pluginsPerPage + 1;
       const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/plugins/?all',
+      });
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
@@ -1435,6 +1592,7 @@
       return this._fetchJSON({
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1445,6 +1603,7 @@
         method: 'POST',
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         body: repoInfo,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1454,6 +1613,7 @@
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
         parseResponse: true,
+        anonymizedUrl: '/projects/*/access:review',
       });
     },
 
@@ -1469,6 +1629,7 @@
         url: '/groups/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1488,6 +1649,7 @@
         url: '/projects/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1506,6 +1668,7 @@
         url: '/accounts/',
         errFn: opt_errFn,
         params,
+        anonymizedUrl: '/accounts/?n=*',
       });
     },
 
@@ -1541,6 +1704,7 @@
         changeNum,
         endpoint: '/related',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1548,6 +1712,7 @@
       return this._getChangeURLAndFetch({
         changeNum,
         endpoint: '/submitted_together',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1560,7 +1725,11 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/conflicts:*',
+      });
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1578,7 +1747,11 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/change:*',
+      });
     },
 
     getChangesWithSameTopic(topic) {
@@ -1592,7 +1765,11 @@
         O: options,
         q: 'status:open topic:' + topic,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/topic:*',
+      });
     },
 
     getReviewedFiles(changeNum, patchNum) {
@@ -1600,6 +1777,7 @@
         changeNum,
         endpoint: '/files?reviewed',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1617,6 +1795,7 @@
         patchNum,
         endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
         errFn: opt_errFn,
+        anonymizedEndpoint: '/files/*/reviewed',
       });
     },
 
@@ -1649,6 +1828,7 @@
           changeNum,
           endpoint: '/edit/',
           params,
+          reportEndpointAsIs: true,
         });
       });
     },
@@ -1679,6 +1859,7 @@
           base_commit: opt_baseCommit,
         },
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1725,6 +1906,7 @@
         endpoint: `/files/${encodeURIComponent(path)}/content`,
         errFn: opt_errFn,
         headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/files/*/content',
       });
     },
 
@@ -1739,6 +1921,7 @@
         method: 'GET',
         endpoint: '/edit/' + encodeURIComponent(path),
         headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1747,6 +1930,7 @@
         changeNum,
         method: 'POST',
         endpoint: '/edit:rebase',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1755,6 +1939,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/edit',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1764,6 +1949,7 @@
         method: 'POST',
         endpoint: '/edit',
         body: {restore_path},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1773,6 +1959,7 @@
         method: 'POST',
         endpoint: '/edit',
         body: {old_path, new_path},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1781,6 +1968,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/edit/' + encodeURIComponent(path),
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1791,6 +1979,7 @@
         endpoint: '/edit/' + encodeURIComponent(path),
         body: contents,
         contentType: 'text/plain',
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1801,6 +1990,7 @@
         method: 'PUT',
         endpoint: '/edit:message',
         body: {message},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1809,6 +1999,7 @@
         changeNum,
         method: 'POST',
         endpoint: '/edit:publish',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1818,13 +2009,16 @@
         method: 'PUT',
         endpoint: '/message',
         body: {message},
+        reportEndpointAsIs: true,
       });
     },
 
     saveChangeStarred(changeNum, starred) {
-      const url = '/accounts/self/starred.changes/' + changeNum;
-      const method = starred ? 'PUT' : 'DELETE';
-      return this._send({method, url});
+      return this._send({
+        method: starred ? 'PUT' : 'DELETE',
+        url: '/accounts/self/starred.changes/' + changeNum,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
     },
 
     /**
@@ -1850,7 +2044,12 @@
       }
       const url = req.url.startsWith('http') ?
           req.url : this.getBaseUrl() + req.url;
-      const xhr = this._fetch(url, options).then(response => {
+      const fetchReq = {
+        url,
+        fetchOptions: options,
+        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+      };
+      const xhr = this._fetch(fetchReq).then(response => {
         if (!response.ok) {
           if (req.errFn) {
             return req.errFn.call(undefined, response);
@@ -1928,6 +2127,7 @@
         errFn: opt_errFn,
         cancelCondition: opt_cancelCondition,
         params,
+        anonymizedEndpoint: '/files/*/diff',
       });
     },
 
@@ -2019,6 +2219,7 @@
           changeNum,
           endpoint,
           patchNum: opt_patchNum,
+          reportEndpointAsIs: true,
         });
       };
 
@@ -2109,8 +2310,10 @@
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
       const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
+      let anonymizedEndpoint = endpoint;
       if (draft.id) {
         endpoint += '/' + draft.id;
+        anonymizedEndpoint += '/*';
       }
       let body;
       if (method === 'PUT') {
@@ -2121,8 +2324,16 @@
         this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
       }
 
-      const promise = this._getChangeURLAndSend(
-          {changeNum, method, patchNum, endpoint, body});
+      const req = {
+        changeNum,
+        method,
+        patchNum,
+        endpoint,
+        body,
+        anonymizedEndpoint,
+      };
+
+      const promise = this._getChangeURLAndSend(req);
       this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
 
       if (isCreate) {
@@ -2136,11 +2347,12 @@
       return this._fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
+        anonymizedUrl: '/projects/*/comments/*',
       });
     },
 
     _fetchB64File(url) {
-      return this._fetch(this.getBaseUrl() + url)
+      return this._fetch({url: this.getBaseUrl() + url})
           .then(response => {
             if (!response.ok) { return Promise.reject(response.statusText); }
             const type = response.headers.get('X-FYI-Content-Type');
@@ -2241,6 +2453,7 @@
         endpoint: '/topic',
         body: {topic},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2256,6 +2469,7 @@
         endpoint: '/hashtags',
         body: hashtag,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2263,6 +2477,7 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2277,11 +2492,15 @@
         url: '/accounts/self/password.http',
         body: {generate: true},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/sshkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountSSHKey(key) {
@@ -2290,6 +2509,7 @@
         url: '/accounts/self/sshkeys',
         body: key,
         contentType: 'plain/text',
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(response => {
@@ -2308,15 +2528,24 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
+        anonymizedUrl: '/accounts/self/sshkeys/*',
       });
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
+      return this._fetchJSON({
+        url: '/accounts/self/gpgkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountGPGKey(key) {
-      const req = {method: 'POST', url: '/accounts/self/gpgkeys', body: key};
+      const req = {
+        method: 'POST',
+        url: '/accounts/self/gpgkeys',
+        body: key,
+        reportUrlAsIs: true,
+      };
       return this._send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
@@ -2334,6 +2563,7 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
+        anonymizedUrl: '/accounts/self/gpgkeys/*',
       });
     },
 
@@ -2342,6 +2572,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+        anonymizedEndpoint: '/reviewers/*/votes/*',
       });
     },
 
@@ -2351,6 +2582,7 @@
         method: 'PUT', patchNum,
         endpoint: '/description',
         body: {description: desc},
+        reportUrlAsIs: true,
       });
     },
 
@@ -2359,6 +2591,7 @@
         method: 'PUT',
         url: '/config/server/email.confirm',
         body: {token},
+        reportUrlAsIs: true,
       };
       return this._send(req).then(response => {
         if (response.status === 204) {
@@ -2372,6 +2605,7 @@
       return this._fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2381,6 +2615,7 @@
         method: 'PUT',
         endpoint: '/assignee',
         body: {assignee},
+        reportUrlAsIs: true,
       });
     },
 
@@ -2389,6 +2624,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/assignee',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2408,7 +2644,13 @@
       if (opt_message) {
         body.message = opt_message;
       }
-      const req = {changeNum, method: 'POST', endpoint: '/wip', body};
+      const req = {
+        changeNum,
+        method: 'POST',
+        endpoint: '/wip',
+        body,
+        reportUrlAsIs: true,
+      };
       return this._getChangeURLAndSend(req).then(response => {
         if (response.status === 204) {
           return 'Change marked as Work In Progress.';
@@ -2428,6 +2670,7 @@
         endpoint: '/ready',
         body: opt_body,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2444,6 +2687,7 @@
         endpoint: `/comments/${commentID}/delete`,
         body: {reason},
         parseResponse: true,
+        anonymizedEndpoint: '/comments/*/delete',
       });
     },
 
@@ -2459,6 +2703,7 @@
       return this._fetchJSON({
         url: `/changes/?q=change:${changeNum}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/changes/?q=change:*',
       }).then(res => {
         if (!res || !res.length) { return null; }
         return res[0];
@@ -2509,6 +2754,11 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndSend(req) {
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._send({
           method: req.method,
@@ -2518,6 +2768,8 @@
           contentType: req.contentType,
           headers: req.headers,
           parseResponse: req.parseResponse,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2528,6 +2780,10 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndFetch(req) {
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._fetchJSON({
           url: url + req.endpoint,
@@ -2535,6 +2791,8 @@
           cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2577,6 +2835,7 @@
         endpoint: `/files/${encodedPath}/blame`,
         patchNum,
         params: opt_base ? {base: 't'} : undefined,
+        anonymizedEndpoint: '/files/*/blame',
       });
     },
 
@@ -2621,7 +2880,11 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
+      return this._fetchSharedCacheURL({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards/*',
+      });
     },
 
     getMergeable(changeNum) {
@@ -2629,6 +2892,7 @@
         changeNum,
         endpoint: '/revisions/current/mergeable',
         parseResponse: true,
+        reportEndpointAsIs: true,
       });
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 70d1465..193d306 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -711,11 +711,10 @@
       sandbox.spy(element, '_send');
       element.confirmEmail('foo');
       assert.isTrue(element._send.calledOnce);
-      assert.deepEqual(element._send.lastCall.args[0], {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token: 'foo'},
-      });
+      assert.equal(element._send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._send.lastCall.args[0].url,
+          '/config/server/email.confirm');
+      assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
     });
 
     test('GrReviewerUpdatesParser.parse is used', () => {
@@ -924,11 +923,10 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args[0], {
-          changeNum: '42',
-          endpoint: '/files?q=test%2Fpath.js',
-          patchNum: 'edit',
-        });
+        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+        assert.equal(fetchStub.lastCall.args[0].endpoint,
+            '/files?q=test%2Fpath.js');
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
       });
     });
 
@@ -1387,12 +1385,25 @@
       sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
       const startTime = 123;
       sandbox.stub(Date, 'now').returns(startTime);
-      return element._fetch(url, fetchOptions).then(() => {
+      const req = {url, fetchOptions};
+      return element._fetch(req).then(() => {
         assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(
-            url, fetchOptions, startTime, response.status));
+        assert.isTrue(logStub.calledWith(req, startTime, response.status));
         assert.isFalse(response.text.called);
       });
     });
+
+    test('_logCall only reports requests with anonymized URLss', () => {
+      sandbox.stub(Date, 'now').returns(200);
+      const handler = sinon.stub();
+      element.addEventListener('rpc-log', handler);
+
+      element._logCall({url: 'url'}, 100, 200);
+      assert.isFalse(handler.called);
+
+      element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+      flushAsynchronousOperations();
+      assert.isTrue(handler.calledOnce);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 6e61c8e..0310e58 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -204,6 +204,7 @@
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+    'safe-types-behavior/safe-types-behavior_test.html',
   ];
   /* eslint-enable max-len */
   for (let file of behaviors) {