Repository: linkerd/linkerd2
Pull request #4057 (2020-02-14 16:06:59)

Use a globing operator in top-line metrics dashboard for the "all" value
Use a globing operator in top-line metrics dashboard for the "all" value

In clusters with many namespaces and deployments, the grafana query to prometheus for "all" may be too large for prometheus to return results.

Instead of having grafana query prometheus for every namespace/deployment literally, we can use a globing operator to shorten the request length so results can be returned in clusters where there are large numbers of deployments and namespaces.

<!--  Thanks for sending a pull request!

If you already have a well-structured git commit message, chances are GitHub
set the title and description of this PR to the git commit message subject and
body, respectively. If so, you may delete these instructions and submit your PR.

If this is your first time, please read our contributor guide:
https://github.com/linkerd/linkerd2/blob/master/CONTRIBUTING.md

The title and description of your Pull Request should match the git commit
subject and body, respectively. Git commit messages are structured as follows:

```
Subject

Problem

Solution

Validation

Fixes #[GitHub issue ID]

DCO Sign off
```

Example git commit message:

```
Introduce Pull Request Template

GitHub's community guidelines recommend a pull request template, the repo was
lacking one.

Introduce a `PULL_REQUEST_TEMPLATE.md` file.

Once merged, the
[Community profile checklist](https://github.com/linkerd/linkerd2/community)
should indicate the repo now provides a pull request template.

Fixes #3321

Signed-off-by: Jane Smith <jane.smith@example.com>
```

Note the git commit message subject becomes the pull request title.

For more details around git commits, see the section on Committing in our
contributor guide:
https://github.com/linkerd/linkerd2/blob/master/CONTRIBUTING.md#committing
-->


ihcsim: Cool! Does this change correspond to what is documented [here](https://grafana.com/docs/grafana/latest/reference/templating/#selection-options) under _Custom all value_? Just want to better understand the context around this change. 

There are a number of other places that still use `"allValue": null`. How do you feel about updating them too?
```
grep -rniI allValue grafana                                                                    
grafana/dashboards/replicaset.json:2211:          "allValue": null,
grafana/dashboards/replicaset.json:2236:          "allValue": null,
grafana/dashboards/replicaset.json:2261:          "allValue": null,
grafana/dashboards/replicaset.json:2286:          "allValue": null,
grafana/dashboards/namespace.json:896:        "allValue": null,
grafana/dashboards/namespace.json:918:        "allValue": null,
grafana/dashboards/service.json:1183:        "allValue": null,
grafana/dashboards/service.json:1205:        "allValue": null,
grafana/dashboards/service.json:1227:        "allValue": null,
grafana/dashboards/service.json:1252:        "allValue": null,
grafana/dashboards/authority.json:1183:        "allValue": null,
grafana/dashboards/authority.json:1205:        "allValue": null,
grafana/dashboards/daemonset.json:2177:        "allValue": null,
grafana/dashboards/daemonset.json:2199:        "allValue": null,
grafana/dashboards/daemonset.json:2221:        "allValue": null,
grafana/dashboards/daemonset.json:2243:        "allValue": null,
grafana/dashboards/cronjob.json:2177:          "allValue": null,
grafana/dashboards/cronjob.json:2199:          "allValue": null,
grafana/dashboards/cronjob.json:2221:          "allValue": null,
grafana/dashboards/cronjob.json:2243:          "allValue": null,
grafana/dashboards/pod.json:2124:        "allValue": null,
grafana/dashboards/pod.json:2146:        "allValue": null,
grafana/dashboards/pod.json:2168:        "allValue": null,
grafana/dashboards/pod.json:2190:        "allValue": null,
grafana/dashboards/pod.json:2215:        "allValue": null,
grafana/dashboards/health.json:2236:        "allValue": null,
grafana/dashboards/health.json:2261:        "allValue": null,
grafana/dashboards/health.json:2286:        "allValue": null,
grafana/dashboards/kubernetes.json:2188:        "allValue": ".*",
grafana/dashboards/statefulset.json:2177:        "allValue": null,
grafana/dashboards/statefulset.json:2199:        "allValue": null,
grafana/dashboards/statefulset.json:2221:        "allValue": null,
grafana/dashboards/statefulset.json:2243:        "allValue": null,
grafana/dashboards/deployment.json:2177:        "allValue": null,
grafana/dashboards/deployment.json:2199:        "allValue": null,
grafana/dashboards/deployment.json:2221:        "allValue": null,
grafana/dashboards/deployment.json:2243:        "allValue": null,
grafana/dashboards/replicationcontroller.json:2177:        "allValue": null,
grafana/dashboards/replicationcontroller.json:2199:        "allValue": null,
grafana/dashboards/replicationcontroller.json:2221:        "allValue": null,
grafana/dashboards/replicationcontroller.json:2243:        "allValue": null,
grafana/dashboards/prometheus-benchmark.json:3644:        "allValue": null,
grafana/dashboards/prometheus-benchmark.json:3666:        "allValue": null,
grafana/dashboards/job.json:2177:        "allValue": null,
grafana/dashboards/job.json:2199:        "allValue": null,
grafana/dashboards/job.json:2221:        "allValue": null,
grafana/dashboards/job.json:2243:        "allValue": null,
```
mmiller1: Yes, @ihcsim that is the documentation section that discusses what this changes, I don't see any reason why no to make the change to the other variables, so I added a commit to do so.

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aa967c0fe..010165911 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -230,7 +230,9 @@ jobs:
     - name: Set environment variables from scripts
       run: |
         . bin/_tag.sh
+        . bin/_release.sh
         echo ::set-env name=TAG::$(CI_FORCE_CLEAN=1 bin/root-tag)
+        extract_release_notes NOTES.md
     - name: Pull CLI binaries
       run : bin/docker-pull-binaries $TAG
     - name: Create release
@@ -242,6 +244,7 @@ jobs:
       with:
         draft: false
         prelease: false
+        body_path: NOTES.md
         files: |
           ./target/release/linkerd2-cli-*-darwin
           ./target/release/linkerd2-cli-*-darwin.sha256
@@ -253,7 +256,7 @@ jobs:
     name: Linkerd website publish
     if: startsWith(github.ref, 'refs/tags/stable') || startsWith(github.ref, 'refs/tags/edge')
     runs-on: ubuntu-18.04
-    needs: [kind_integration_tests, cloud_integration_tests]
+    needs: [gh_release]
     steps:
     - name: Create linkerd/website repository dispatch event
       # peter-evans/repository-dispatch@v1
@@ -286,7 +289,7 @@ jobs:
         until RES=$(curl -sL https://run.linkerd.io/$INSTALL | grep "LINKERD2_VERSION=\${LINKERD2_VERSION:-$TAG}") \
           || (( count++ >= 10 ))
         do
-          sleep 5
+          sleep 30
         done
         if [[ -z $RES ]]; then
           echo "::error::The version '$TAG' was NOT found published in the website"
diff --git a/.proxy-version b/.proxy-version
index dcf654e77..f9993ec65 100644
--- a/.proxy-version
+++ b/.proxy-version
@@ -1,1 +1,1 @@
-v2.87.0
+v2.88.0
diff --git a/CHANGES.md b/CHANGES.md
index b1d9de1b1..70716490a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,27 @@
+## edge-20.3.1
+
+This release introduces new functionality mainly focused around
+observability and multi-cluster support via `service mirroring`.
+
+If you would like to learn more about `service mirroring` or are interested in
+experimenting with this feature, please join us in
+[Linkerd Slack](https://slack.linkerd.io) for help and feedback.
+
+* CLI
+  * Improved the `linkerd check` command to check for extension server
+    certificate (thanks @christyjacob4!)
+* Controller
+  * Removed restrictions preventing Linkerd from injecting proxies into
+    Contour (thanks @alfatraining!)
+  * Added an experimental version of a service mirroring controller, allowing
+    discovery of services on remote clusters.
+* Web UI
+  * Fixed a bug causing incorrect Grafana links to be rendered in the web
+    dashboard.
+* Proxy
+  * Fixed a bug that could cause the proxy's load balancer to stop processing
+    updates from service discovery.
+    
 ## edge-20.2.3
 
 This release introduces the first optional add-on `tracing`, added through the
@@ -157,13 +181,13 @@ Please see the [upgrade instructions](https://linkerd.io/2/tasks/upgrade/#upgrad
   * Fixed a race condition in the `linkerd-web` service
   * Updated Prometheus to 2.15.2 (thanks @Pothulapati)
   * Increased minimum kubernetes version to 1.13.0
-  * Added support for pod ip and service cluster ip lookups in the destination 
+  * Added support for pod ip and service cluster ip lookups in the destination
     service
   * Added recommended kubernetes labels to control-plane
-  * Added the `--wait-before-exit-seconds` flag to linkerd inject for the proxy 
-    sidecar to delay the start of its shutdown process (a huge commit from 
+  * Added the `--wait-before-exit-seconds` flag to linkerd inject for the proxy
+    sidecar to delay the start of its shutdown process (a huge commit from
     @KIVagant, thanks!)
-  * Added a pre-sign check to the identity service 
+  * Added a pre-sign check to the identity service
   * Fixed inject failures for pods with security context capabilities
   * Added `conntrack` to the `debug` container to help with connection tracking
     debugging
@@ -200,7 +224,7 @@ Please see the [upgrade instructions](https://linkerd.io/2/tasks/upgrade/#upgrad
 * Web UI
   * Fixed an error when refreshing an already open dashboard when the Linkerd
     version has changed
-  * Increased the speed of the dashboard by pausing network activity when the 
+  * Increased the speed of the dashboard by pausing network activity when the
     dashboard is not visible to the user
   * Added support for CronJobs and ReplicaSets, including new Grafana dashboards
     for them
@@ -218,7 +242,7 @@ Please see the [upgrade instructions](https://linkerd.io/2/tasks/upgrade/#upgrad
     the value of `linkerd.io/inject` is either `enabled` or `disabled`
     (thanks @mayankshah1607)
   * Upgraded the Prometheus Go client library to v1.2.1 (thanks @daxmc99!)
-  * Fixed an issue causing `tap`, `injector` and `sp-validator` to use 
+  * Fixed an issue causing `tap`, `injector` and `sp-validator` to use
     old certificates after `helm upgrade` due to not being restarted
   * Fixed incomplete Swagger definition of the tap api, causing benign
     error logging in the kube-apiserver
@@ -310,7 +334,7 @@ have installed Linkerd using Helm. In order to make the purpose of the
   * Added validation to incoming sidecar injection requests that ensures
     the value of `linkerd.io/inject` is either `enabled` or `disabled`
     (thanks @mayankshah1607)
-    
+
 ## edge-20.1.1
 
 This edge release includes experimental improvements to the Linkerd proxy's
@@ -346,23 +370,23 @@ might be stability issues.
 
 ## edge-19.12.3
 
-This edge release adds support for pod IP and service cluster IP lookups, 
-improves performance of the dashboard, and makes `linkerd check --pre` perform 
+This edge release adds support for pod IP and service cluster IP lookups,
+improves performance of the dashboard, and makes `linkerd check --pre` perform
 more comprehensive checks.
 
 The `--wait-before-exit-seconds` flag has been added to allow Linkerd users to
  opt in
-to `preStop hooks`. The details of this change are in 
+to `preStop hooks`. The details of this change are in
 [#3798](https://github.com/linkerd/linkerd2/pull/3798).
 
 Also, the proxy has been updated to `v2.82.0` which improves gRPC error
-classification and 
-[ensures that resolutions](https://github.com/linkerd/linkerd2/pull/3848) are 
+classification and
+[ensures that resolutions](https://github.com/linkerd/linkerd2/pull/3848) are
 released when the associated balancer becomes idle.
 
 Finally, an update to follow best practices in the Helm charts has caused a
 *breaking change*. Users who have installed Linkerd using Helm must be
-certain to read the details of 
+certain to read the details of
 [#3822](https://github.com/linkerd/linkerd2/issues/3822)
 
 * CLI
@@ -370,15 +394,15 @@ certain to read the details of
   * Added TLS certificate validation to `check` and `upgrade` commands
 * Controller
   * Increased minimum kubernetes version to 1.13.0
-  * Added support for pod ip and service cluster ip lookups in the destination 
+  * Added support for pod ip and service cluster ip lookups in the destination
     service
   * Added recommended kubernetes labels to control-plane
-  * Added the `--wait-before-exit-seconds` flag to linkerd inject for the proxy 
-    sidecar to delay the start of its shutdown process (a huge commit from 
+  * Added the `--wait-before-exit-seconds` flag to linkerd inject for the proxy
+    sidecar to delay the start of its shutdown process (a huge commit from
     @KIVagant, thanks!)
   * Added a pre-sign check to the identity service
 * Web UI
-  * Increased the speed of the dashboard by pausing network activity when the 
+  * Increased the speed of the dashboard by pausing network activity when the
     dashboard is not visible to the user
 * Proxy
   * Added a timeout to release resolutions to idle balancers
@@ -386,7 +410,7 @@ certain to read the details of
 * Internal
   * **Breaking Change** Updated Helm charts to follow best practices using
     proper casing (thanks @Pothulapati!)
-  
+
 ## edge-19.12.2
 
 * CLI
@@ -426,11 +450,11 @@ certain to read the details of
   * Added a check that ensures using `--namespace` and `--all-namespaces`
     results in an error as they are mutually exclusive
 * Internal
-  * Fixed an issue causing `tap`, `injector` and `sp-validator` to use 
+  * Fixed an issue causing `tap`, `injector` and `sp-validator` to use
     old certificates after `helm upgrade` due to not being restarted
   * Fixed incomplete Swagger definition of the tap api, causing benign
     error logging in the kube-apiserver
-    
+
 ## edge-19.11.2
 
 * CLI
diff --git a/bin/_release.sh b/bin/_release.sh
new file mode 100644
index 000000000..ea379bef5
--- /dev/null
+++ b/bin/_release.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# shellcheck disable=SC2120
+# (disabling SC2120 so that we can use functions with optional args
+# see https://github.com/koalaman/shellcheck/wiki/SC2120#exceptions )
+
+set -eu
+
+extract_release_notes() {
+  bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
+  rootdir=$( cd "$bindir"/.. && pwd )
+
+  if [ $# -eq 0 ]
+  then
+    # Make temporary file to save the release commit message into.
+    tmp=$(mktemp -t release-commit-message.XXX.txt)
+  else
+    tmp="$rootdir/$1"
+  fi
+
+  # Save commit message into temporary file.
+  #
+  # Match each occurence of the regex and increment `n` by 1. While n == 1
+  # (which is true only for the first section) print that line of `CHANGES.md`.
+  # This ends up being the first section of release changes.
+  awk '/^## (edge|stable)-[0-9]+\.[0-9]+\.[0-9]+/{n++} n==1' "$rootdir"/CHANGES.md > "$tmp"
+
+  echo "$tmp"
+}
diff --git a/bin/create-release-tag b/bin/create-release-tag
index b6a8c0aec..ab95ff9c4 100755
--- a/bin/create-release-tag
+++ b/bin/create-release-tag
@@ -3,49 +3,98 @@
 set -o errexit
 set -o nounset
 
-if [[ $# -ne 1 ]]; then
-    echo "usage: ${0##*/} (edge|stable)-xx.xx.xx" >&2
+# Assert at least one and at most two arguments were passed.
+if [[ $# -lt 1 || $# -gt 2 ]]; then
+    echo "Error: ${0##*/} accepts 1 or 2 arguments" >&2
+    echo "Usage:" >&2
+    echo "    ${0##*/} edge" >&2
+    echo "    ${0##*/} stable x.x.x" >&2
     exit 1
 fi
 
-tag="$1"
-
-# Verify the tag format
-tag_format="^(edge|stable)-([0-9]+)\.([0-9]+)\.([0-9]+)$"
-if [[ $tag =~ $tag_format ]]; then
-    # todo: Use these values to verify the tag version increment.
-    # release_channel="${BASH_REMATCH[1]}"
-    # release_major="${BASH_REMATCH[2]}"
-    # release_minor="${BASH_REMATCH[3]}"
-    # release_patch="${BASH_REMATCH[4]}"
-    :
-else
-    echo "tag format incorrect; expected: $tag_format"
-    echo "example: edge-20.12.2, stable-2.10.1"
+release_channel="$1"
+
+# Verify the release channel.
+release_channel_regex="(edge|stable)"
+if [[ ! $release_channel =~ $release_channel_regex ]]; then
+    echo "Error: valid release channels: edge, stable"
+    echo "Usage:"
+    echo "    bin/create-release-tag edge"
+    echo "    bin/create-release-tag stable 2.4.8"
     exit 1
 fi
 
-# todo: Verify the tag version increment.
+if [ "$release_channel" == "stable" ]; then
+    if [ $# -ne 2 ]; then
+        echo "Error: accepts 2 arguments"
+        echo "Usage:"
+        echo "    bin/create-release-tag stable 2.4.8"
+        exit 1
+    fi
+
+    stable_tag="$2"
+
+    # Verify the stable tag format.
+    stable_tag_regex="^([0-9]+)\.([0-9]+)\.([0-9]+)$"
+    if [[ ! $stable_tag =~ $stable_tag_regex ]]; then
+        echo "Error: version reference incorrect"
+        echo "Usage:"
+        echo "    bin/create-release-tag stable 2.4.8"
+        exit 1
+    fi
+
+    # Set the script global `release_tag` variable.
+    release_tag="stable-$stable_tag"
+fi
+
+if [ "$release_channel" == "edge" ]; then
+if [ $# -ne 1 ]; then
+        echo "Error: accepts 1 argument"
+        echo "Usage:"
+        echo "    bin/create-release-tag edge"
+        exit 1
+    fi
 
-rootdir=$( cd "${0%/*}"/.. && pwd )
+    # Some awks on Linux do no support quantifiers in regex, so this regex is
+    # explicit.
+    edge_tag_regex="(edge)-([0-9][0-9])\.([0-9]|[0-9][0-9])\.([0-9]+)"
+    url="https://run.linkerd.io/install-edge"
 
-# Make temporary file to save the release commit message into.
-tmp=$(mktemp -t release-commit-message.XXX.txt)
+    # Get the current edge version.
+    current_edge=$(curl -sL $url | awk -v tag_format="$edge_tag_regex" '$0 ~ tag_format')
+
+    # Get the third and fourth groups of the regex; they are the month and
+    # month minor values for the current edge version.
+    current_mm=$(echo "$current_edge" | sed -n -E "s/.*$edge_tag_regex}$/\3/p")
+    current_xx=$(echo "$current_edge" | sed -n -E "s/.*$edge_tag_regex}$/\4/p")
+
+    yy=$(date +"%y")
+    
+    # Strip leading zero
+    new_mm=$(date +"%-m")
+
+    # If this is a new month, `new_xx` should be 1; otherwise increment it.
+    if [ ! "$new_mm" == "$current_mm" ]; then
+        new_xx="1"
+    else
+        new_xx=$((current_xx+1))
+    fi
+
+    # Set the script global `release_tag` variable.
+    release_tag="edge-$yy.$new_mm.$new_xx"
+fi
 
-# Save commit message into temporary file.
-#
-# Match each occurence of the regex and increment `n` by 1. While n == 1
-# (which is true only for the first section) print that line of `CHANGES.md`.
-# This ends up being the first section of release changes.
-awk '/^## (edge|stable)-[0-9]+\.[0-9]+\.[0-9]+/{n++} n==1' "$rootdir"/CHANGES.md > "$tmp"
+bindir=$( cd "${BASH_SOURCE[0]%/*}" && pwd )
+# shellcheck source=bin/_release.sh
+tmp=$(. "$bindir"/_release.sh; extract_release_notes)
 
-# Create an unsigned tag with the commit message.
-git tag -s -F "$tmp" "$tag"
+# Create a signed tag with the commit message.
+git tag -s -F "$tmp" "$release_tag"
 
 # Success message
-echo "tag created and signed."
+echo "$release_tag tag created and signed."
 echo ""
-echo "tag: $tag"
+echo "tag: $release_tag"
 echo ""
 echo "To push tag, run:"
-echo "    git push origin $tag"
+echo "    git push origin $release_tag"
diff --git a/charts/linkerd2/README.md b/charts/linkerd2/README.md
index 4d576c57d..d5e1a3400 100644
--- a/charts/linkerd2/README.md
+++ b/charts/linkerd2/README.md
@@ -155,6 +155,28 @@ The following table lists the configurable parameters of the Linkerd2 chart and
 | `webhookFailurePolicy`                | Failure policy for the proxy injector                                                                                                                                                 | `Ignore`                             |
 | `webImage`                            | Docker image for the web container                                                                                                                                                    | `gcr.io/linkerd-io/web`              |
 
+## Add-Ons Configuration
+
+### Tracing Add-On
+
+The following table lists the configurable parameters for the Tracing Add-On.
+
+| Parameter                             | Description                                                                                                                                                                           | Default                              |
+|:--------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------|
+| `tracing.enabled`                     | Flag to enable tracing components to be installed                                                                                                                                                | `false`
+| `tracing.collector.name`                | Name of the trace collector Service                                                                                                                                                 | `linkerd-collector`                             |
+| `tracing.collector.image`                | Docker image for the trace collector                                                                                                                                                 | `omnition/opencensus-collector:0.1.10`                             |
+| `tracing.collector.resources.cpu.limit`       | Maximum amount of CPU units that the trace collector container can use                                                                                                                     | `1`                               |
+| `tracing.collector.resources.cpu.request`     | Amount of CPU units that the trace collector container requests                                                                                                                            | `200m`                                |
+| `tracing.collector.resources.memory.limit`    | Maximum amount of memory that the trace collector container can use                                                                                                                        | `2Gi`                               |
+| `tracing.collector.resources.memory.request`  | Amount of memory that the trace collector container requests                                                                                                                               | `400Mi`                               |
+| `tracing.jaeger.name`                | Name of the jaeger instance                                                                                                                                                 | `linkerd-jaeger`                             |
+| `tracing.jaeger.image`                | Docker image for the jaeger instance                                                                                                                                                 | `jaegertracing/all-in-one:1.8`                             |
+| `tracing.jaeger.resources.cpu.limit`       | Maximum amount of CPU units that the jaeger container can use                                                                                                                     ||
+| `tracing.jaeger.resources.cpu.request`     | Amount of CPU units that the jaeger container requests                                                                                                                            ||
+| `tracing.jaeger.resources.memory.limit`    | Maximum amount of memory that the jaeger container can use                                                                                                                        ||
+| `tracing.jaeger.resources.memory.request`  | Amount of memory that the jaeger container requests                                                                                                                               ||
+
 ## Get involved
 
 * Check out Linkerd's source code at [Github][linkerd2].
diff --git a/cli/cmd/alpha.go b/cli/cmd/alpha.go
new file mode 100644
index 000000000..24ec95192
--- /dev/null
+++ b/cli/cmd/alpha.go
@@ -0,0 +1,17 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+func newCmdAlpha() *cobra.Command {
+	alphaCmd := &cobra.Command{
+		Use:   "alpha",
+		Short: "experimental subcommands for Linkerd",
+		Args:  cobra.NoArgs,
+	}
+
+	alphaCmd.AddCommand(newCmdAlphaStat())
+
+	return alphaCmd
+}
diff --git a/cli/cmd/alpha_stat.go b/cli/cmd/alpha_stat.go
new file mode 100644
index 000000000..41f20e679
--- /dev/null
+++ b/cli/cmd/alpha_stat.go
@@ -0,0 +1,316 @@
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"time"
+
+	"github.com/linkerd/linkerd2/cli/table"
+	"github.com/linkerd/linkerd2/controller/api/util"
+	"github.com/linkerd/linkerd2/controller/gen/public"
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	"github.com/linkerd/linkerd2/pkg/smimetrics"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+var allowedKinds = map[string]struct{}{
+	k8s.CronJob:               struct{}{},
+	k8s.DaemonSet:             struct{}{},
+	k8s.Deployment:            struct{}{},
+	k8s.Job:                   struct{}{},
+	k8s.Namespace:             struct{}{},
+	k8s.Pod:                   struct{}{},
+	k8s.ReplicaSet:            struct{}{},
+	k8s.ReplicationController: struct{}{},
+	k8s.StatefulSet:           struct{}{},
+}
+
+type alphaStatOptions struct {
+	namespace     string
+	toResource    string
+	allNamespaces bool
+}
+
+func newCmdAlphaStat() *cobra.Command {
+	options := alphaStatOptions{
+		namespace: "default",
+	}
+
+	statCmd := &cobra.Command{
+		Use:   "stat [flags] (RESOURCE)",
+		Short: "Display traffic stats about one or many resources",
+		Long: `Display traffic stats about one or many resources
+		
+(RESOURCE) can be a resource kind; one of:
+  * cronjobs
+  * daemonsets
+  * deployments
+  * jobs
+  * namespaces
+  * pods
+  * replicasets
+  * replicationcontrollers
+  * statefulsets
+or it may be a specific named resource of one of the above kinds.
+
+linkerd alpha stat will return a table of the requested resource or resources
+showing the top-line metrics for those resources such as request rate, success
+rate, and latency percentiles.  These values are measured on the server-side
+unless the --to flag is specified.
+
+The --to flag accepts a resource kind or a specific resource and instead
+displays the metrics measured on the client side from the root resource to
+the to-resource.  The root resource must be a specific named resource.
+
+Examples:
+  # Topline Resource Metrics
+  linkerd alpha stat -n emojivoto deploy/web
+
+  # Topline Resource Metrics for a whole Kind
+  linkerd alpha stat -n emojivoto deploy
+
+  # Outbound edges
+  linkerd alpha stat -n emojivoto deploy/web --to=deploy
+
+  # Outbound to a specific destination
+  linkerd alpha stat -n emojivoto deploy/web --to=deploy/emoji`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
+			if err != nil {
+				return err
+			}
+
+			namespace := options.namespace
+			if options.allNamespaces {
+				namespace = corev1.NamespaceAll
+			}
+			target, err := util.BuildResource(namespace, args[0])
+			if err != nil {
+				return err
+			}
+			if target.GetName() != "" && options.allNamespaces {
+				// Getting a named resource from all-namespaces is not supported.
+				return errors.New("cannot use --all-namespaces flag with a named target resource")
+			}
+
+			if _, ok := allowedKinds[target.GetType()]; !ok {
+				return fmt.Errorf("%s is not a supported resource type", target.GetType())
+			}
+			kind, err := k8s.PluralResourceNameFromFriendlyName(target.GetType())
+			if err != nil {
+				return err
+			}
+			name := target.GetName()
+			toResource := buildToResource("", options.toResource)
+			// TODO: Lift this requirement once the API supports it.
+			if toResource != nil && toResource.GetType() != target.GetType() {
+				return errors.New("the --to resource must have the same kind as the target resource")
+			}
+
+			if name != "" {
+				if toResource != nil {
+					metrics, err := smimetrics.GetTrafficMetricsEdgesList(k8sAPI, target.GetNamespace(), kind, name, nil)
+					if err != nil {
+						return err
+					}
+					renderTrafficMetricsEdgesList(metrics, stdout, toResource)
+				} else {
+					metrics, err := smimetrics.GetTrafficMetrics(k8sAPI, target.GetNamespace(), kind, name, nil)
+					if err != nil {
+						return err
+					}
+					renderTrafficMetrics(metrics, options.allNamespaces, stdout)
+				}
+			} else {
+				if toResource != nil {
+					return errors.New("the --to flag requires that the target resource name be specified")
+				}
+				metrics, err := smimetrics.GetTrafficMetricsList(k8sAPI, target.GetNamespace(), kind, nil)
+				if err != nil {
+					return err
+				}
+				renderTrafficMetricsList(metrics, options.allNamespaces, stdout)
+			}
+
+			return nil
+		},
+	}
+
+	statCmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the specified resource")
+	statCmd.PersistentFlags().StringVar(&options.toResource, "to", options.toResource, "If present, restricts outbound stats to the specified resource name")
+	statCmd.PersistentFlags().BoolVarP(&options.allNamespaces, "all-namespaces", "A", options.allNamespaces, "Ignore the --namespace flag and fetches data from all namespaces")
+
+	return statCmd
+}
+
+func buildToResource(namespace, to string) *public.Resource {
+	toResource, err := util.BuildResource(namespace, to)
+	if err != nil {
+		log.Debugf("Invalid to resource: %s", err)
+		return nil
+
+	}
+	log.Debugf("Using to resource: %v", toResource)
+	return &toResource
+}
+
+func renderTrafficMetrics(metrics *smimetrics.TrafficMetrics, allNamespaces bool, w io.Writer) {
+	t := buildTable(false, allNamespaces)
+	t.Data = []table.Row{metricsToRow(metrics, false)}
+	t.Render(w)
+}
+
+func renderTrafficMetricsList(metrics *smimetrics.TrafficMetricsList, allNamespaces bool, w io.Writer) {
+	t := buildTable(false, allNamespaces)
+	t.Data = []table.Row{}
+	for _, row := range metrics.Items {
+		row := row // Copy to satisfy golint.
+		t.Data = append(t.Data, metricsToRow(&row, false))
+	}
+	t.Render(w)
+}
+
+func renderTrafficMetricsEdgesList(metrics *smimetrics.TrafficMetricsList, w io.Writer, toResource *public.Resource) {
+	t := buildTable(true, false)
+	t.Data = []table.Row{}
+	for _, row := range metrics.Items {
+		row := row // Copy to satisfy golint.
+		if row.Edge.Direction != "to" {
+			continue
+		}
+		if toResource != nil {
+			if toResource.GetName() != "" && row.Edge.Resource.Name != toResource.GetName() {
+				log.Debugf("Skipping edge %v", row.Edge.Resource)
+				continue
+			}
+			if toResource.GetNamespace() != "" && row.Edge.Resource.Namespace != toResource.GetNamespace() {
+				log.Debugf("Skipping edge %v", row.Edge.Resource)
+				continue
+			}
+		}
+		t.Data = append(t.Data, metricsToRow(&row, true))
+	}
+	t.Render(w)
+}
+
+func getNumericMetric(metrics *smimetrics.TrafficMetrics, name string) *resource.Quantity {
+	for _, m := range metrics.Metrics {
+		if m.Name == name {
+			quantity, err := resource.ParseQuantity(m.Value)
+			if err != nil {
+				return resource.NewQuantity(0, resource.DecimalSI)
+			}
+			return &quantity
+		}
+	}
+	return resource.NewQuantity(0, resource.DecimalSI)
+}
+
+func getNumericMetricWithUnit(metrics *smimetrics.TrafficMetrics, name string) string {
+	for _, m := range metrics.Metrics {
+		if m.Name == name {
+			quantity, err := resource.ParseQuantity(m.Value)
+			if err != nil {
+				return ""
+			}
+			value := quantity.Value()
+			return fmt.Sprintf("%d%s", value, m.Unit)
+		}
+	}
+	return ""
+}
+
+func metricsToRow(metrics *smimetrics.TrafficMetrics, outbound bool) []string {
+	success := getNumericMetric(metrics, "success_count").MilliValue()
+	failure := getNumericMetric(metrics, "failure_count").MilliValue()
+	sr := "-"
+	if success+failure > 0 {
+		rate := float32(success) / float32(success+failure) * 100
+		sr = fmt.Sprintf("%.2f%%", rate)
+	}
+	rps := "-"
+	window, err := time.ParseDuration(metrics.Window)
+	if err == nil {
+		rate := float64(success+failure) / 1000.0 / window.Seconds()
+		rps = fmt.Sprintf("%.1frps", rate)
+	}
+
+	var to string
+	if outbound {
+		to = metrics.Edge.Resource.String()
+	}
+
+	return []string{
+		metrics.Resource.Namespace,
+		metrics.Resource.Name, // Name
+		metrics.Resource.Name, // From
+		to,                    // To
+		sr,
+		rps,
+		getNumericMetricWithUnit(metrics, "p50_response_latency"),
+		getNumericMetricWithUnit(metrics, "p90_response_latency"),
+		getNumericMetricWithUnit(metrics, "p99_response_latency"),
+	}
+}
+
+func buildTable(outbound, allNamespaces bool) table.Table {
+	columns := []table.Column{
+		table.Column{
+			Header:    "NAMESPACE",
+			Width:     9,
+			Hide:      !allNamespaces,
+			Flexible:  true,
+			LeftAlign: true,
+		},
+		table.Column{
+			Header:    "NAME",
+			Width:     4,
+			Hide:      outbound,
+			Flexible:  true,
+			LeftAlign: true,
+		},
+		table.Column{
+			Header:    "FROM",
+			Width:     4,
+			Hide:      !outbound,
+			Flexible:  true,
+			LeftAlign: true,
+		},
+		table.Column{
+			Header:    "TO",
+			Width:     2,
+			Hide:      !outbound,
+			Flexible:  true,
+			LeftAlign: true,
+		},
+		table.Column{
+			Header: "SUCCESS",
+			Width:  7,
+		},
+		table.Column{
+			Header: "RPS",
+			Width:  9,
+		},
+		table.Column{
+			Header: "LATENCY_P50",
+			Width:  11,
+		},
+		table.Column{
+			Header: "LATENCY_P90",
+			Width:  11,
+		},
+		table.Column{
+			Header: "LATENCY_P99",
+			Width:  11,
+		},
+	}
+	t := table.NewTable(columns, []table.Row{})
+	t.Sort = []int{0, 1} // Sort by namespace, then name.
+	return t
+}
diff --git a/cli/cmd/root.go b/cli/cmd/root.go
index 3b8aac6c3..0873e4839 100644
--- a/cli/cmd/root.go
+++ b/cli/cmd/root.go
@@ -101,6 +101,7 @@ func init() {
 	RootCmd.PersistentFlags().StringArrayVar(&impersonateGroup, "as-group", []string{}, "Group to impersonate for Kubernetes operations")
 	RootCmd.PersistentFlags().StringVar(&apiAddr, "api-addr", "", "Override kubeconfig and communicate directly with the control plane at host:port (mostly for testing)")
 	RootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Turn on debug logging")
+	RootCmd.AddCommand(newCmdAlpha())
 	RootCmd.AddCommand(newCmdCheck())
 	RootCmd.AddCommand(newCmdCompletion())
 	RootCmd.AddCommand(newCmdDashboard())
diff --git a/cli/table/table.go b/cli/table/table.go
new file mode 100644
index 000000000..8f6fafa77
--- /dev/null
+++ b/cli/table/table.go
@@ -0,0 +1,113 @@
+package table
+
+import (
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+)
+
+type (
+	// Table represts a table of data to be rendered.
+	Table struct {
+		Columns       []Column
+		Data          []Row
+		Sort          []int
+		ColumnSpacing string
+	}
+
+	// Row is a single row of data in a table.
+	Row = []string
+
+	// Column represents metadata about a column in a table.
+	Column struct {
+		Header string
+		Width  int
+		// If false, render this column.
+		Hide bool
+		// If true, set the width to the widest value in this column.
+		Flexible  bool
+		LeftAlign bool
+	}
+)
+
+const defaultColumnSpacing = "  "
+
+// NewTable creates a new table with the given columns and rows.
+func NewTable(cols []Column, data []Row) Table {
+	return Table{
+		Columns:       cols,
+		Data:          data,
+		Sort:          []int{},
+		ColumnSpacing: defaultColumnSpacing,
+	}
+}
+
+// Render writes the full table to the given Writer.
+func (t *Table) Render(w io.Writer) {
+	columnWidths := t.columnWidths()
+	t.renderRow(w, t.headerRow(), columnWidths)
+	t.sort()
+	for _, row := range t.Data {
+		t.renderRow(w, row, columnWidths)
+	}
+}
+
+func (t *Table) columnWidths() []int {
+	widths := make([]int, len(t.Columns))
+	for c, col := range t.Columns {
+		width := col.Width
+		if col.Flexible {
+			for _, row := range t.Data {
+				if len(row[c]) > width {
+					width = len(row[c])
+				}
+			}
+		}
+		widths[c] = width
+	}
+	return widths
+}
+
+func (t *Table) sort() {
+	if len(t.Sort) == 0 {
+		return
+	}
+	sort.Slice(t.Data, func(i, j int) bool {
+		for _, sortCol := range t.Sort {
+			if t.Data[i][sortCol] < t.Data[j][sortCol] {
+				return true
+			} else if t.Data[i][sortCol] > t.Data[j][sortCol] {
+				return false
+			}
+		}
+		return false
+	})
+}
+
+func (t *Table) renderRow(w io.Writer, row Row, columnWidths []int) {
+	for c, col := range t.Columns {
+		if col.Hide {
+			continue
+		}
+		value := row[c]
+		if len(value) > columnWidths[c] {
+			value = value[:columnWidths[c]]
+		}
+		padding := strings.Repeat(" ", columnWidths[c]-len(value))
+		if col.LeftAlign {
+			fmt.Fprintf(w, "%s%s%s", value, padding, t.ColumnSpacing)
+		} else {
+			fmt.Fprintf(w, "%s%s%s", padding, value, t.ColumnSpacing)
+		}
+	}
+	fmt.Fprint(w, "\n")
+}
+
+func (t *Table) headerRow() Row {
+	row := make(Row, len(t.Columns))
+	for c, col := range t.Columns {
+		row[c] = col.Header
+	}
+	return row
+}
diff --git a/controller/api/destination/endpoint_translator.go b/controller/api/destination/endpoint_translator.go
index d8e30d737..c4acdbc7c 100644
--- a/controller/api/destination/endpoint_translator.go
+++ b/controller/api/destination/endpoint_translator.go
@@ -39,9 +39,9 @@ func newEndpointTranslator(
 	return &endpointTranslator{controllerNS, identityTrustDomain, enableH2Upgrade, stream, log}
 }
 
-func (et *endpointTranslator) Add(set watcher.PodSet) {
+func (et *endpointTranslator) Add(set watcher.AddressSet) {
 	addrs := []*pb.WeightedAddr{}
-	for _, address := range set.Pods {
+	for _, address := range set.Addresses {
 		var (
 			wa  *pb.WeightedAddr
 			err error
@@ -49,14 +49,31 @@ func (et *endpointTranslator) Add(set watcher.PodSet) {
 		if address.Pod != nil {
 			wa, err = et.toWeightedAddr(address)
 		} else {
-			// handling address with no associated pod, such as those
-			// belonging to headless services
+			// handling address with no associated pod
 			var addr *net.TcpAddress
 			addr, err = et.toAddr(address)
 			wa = &pb.WeightedAddr{
 				Addr:   addr,
 				Weight: defaultWeight,
 			}
+
+			if address.Identity != "" {
+				wa.TlsIdentity = &pb.TlsIdentity{
+					Strategy: &pb.TlsIdentity_DnsLikeIdentity_{
+						DnsLikeIdentity: &pb.TlsIdentity_DnsLikeIdentity{
+							Name: address.Identity,
+						},
+					},
+				}
+				// in this case we most likely have a proxy on the side, so set protocol hint as well.
+				if et.enableH2Upgrade {
+					wa.ProtocolHint = &pb.ProtocolHint{
+						Protocol: &pb.ProtocolHint_H2_{
+							H2: &pb.ProtocolHint_H2{},
+						},
+					}
+				}
+			}
 		}
 		if err != nil {
 			et.log.Errorf("Failed to translate endpoints to weighted addr: %s", err)
@@ -78,9 +95,9 @@ func (et *endpointTranslator) Add(set watcher.PodSet) {
 	}
 }
 
-func (et *endpointTranslator) Remove(set watcher.PodSet) {
+func (et *endpointTranslator) Remove(set watcher.AddressSet) {
 	addrs := []*net.TcpAddress{}
-	for _, address := range set.Pods {
+	for _, address := range set.Addresses {
 		tcpAddr, err := et.toAddr(address)
 		if err != nil {
 			et.log.Errorf("Failed to translate endpoints to addr: %s", err)
diff --git a/controller/api/destination/watcher/endpoints_watcher.go b/controller/api/destination/watcher/endpoints_watcher.go
index 875c99370..a1ecdfe99 100644
--- a/controller/api/destination/watcher/endpoints_watcher.go
+++ b/controller/api/destination/watcher/endpoints_watcher.go
@@ -7,6 +7,7 @@ import (
 	"sync"
 
 	"github.com/linkerd/linkerd2/controller/k8s"
+	consts "github.com/linkerd/linkerd2/pkg/k8s"
 	"github.com/prometheus/client_golang/prometheus"
 	logging "github.com/sirupsen/logrus"
 	corev1 "k8s.io/api/core/v1"
@@ -24,19 +25,24 @@ const (
 // https://github.com/linkerd/linkerd2/issues/2204
 
 type (
-	// Address represents an individual port on an specific pod.
+	// Address represents an individual port on a specific endpoint.
+	// This endpoint might be the result of a the existence of a pod
+	// that is targeted by this service; alternatively it can be the
+	// case that this endpoint is not associated with a pod and maps
+	// to some other IP (i.e. a remote service gateway)
 	Address struct {
 		IP        string
 		Port      Port
 		Pod       *corev1.Pod
 		OwnerName string
 		OwnerKind string
+		Identity  string
 	}
 
-	// PodSet is a set of pods, indexed by IP.
-	PodSet struct {
-		Pods   map[PodID]Address
-		Labels map[string]string
+	// AddressSet is a set of Address, indexed by ID.
+	AddressSet struct {
+		Addresses map[ID]Address
+		Labels    map[string]string
 	}
 
 	portAndHostname struct {
@@ -89,15 +95,15 @@ type (
 		k8sAPI     *k8s.API
 
 		exists    bool
-		pods      PodSet
+		addresses AddressSet
 		listeners []EndpointUpdateListener
 		metrics   endpointsMetrics
 	}
 
 	// EndpointUpdateListener is the interface that subscribers must implement.
 	EndpointUpdateListener interface {
-		Add(set PodSet)
-		Remove(set PodSet)
+		Add(set AddressSet)
+		Remove(set AddressSet)
 		NoEndpoints(exists bool)
 	}
 )
@@ -417,32 +423,32 @@ func (sp *servicePublisher) metricsLabels(port Port, hostname string) prometheus
 // portPublisher.
 
 func (pp *portPublisher) updateEndpoints(endpoints *corev1.Endpoints) {
-	newPods := pp.endpointsToAddresses(endpoints)
-	if len(newPods.Pods) == 0 {
+	newAddressSet := pp.endpointsToAddresses(endpoints)
+	if len(newAddressSet.Addresses) == 0 {
 		for _, listener := range pp.listeners {
 			listener.NoEndpoints(true)
 		}
 	} else {
-		add, remove := diffPods(pp.pods, newPods)
+		add, remove := diffAddresses(pp.addresses, newAddressSet)
 		for _, listener := range pp.listeners {
-			if len(remove.Pods) > 0 {
+			if len(remove.Addresses) > 0 {
 				listener.Remove(remove)
 			}
-			if len(add.Pods) > 0 {
+			if len(add.Addresses) > 0 {
 				listener.Add(add)
 			}
 		}
 	}
 	pp.exists = true
-	pp.pods = newPods
+	pp.addresses = newAddressSet
 
 	pp.metrics.incUpdates()
-	pp.metrics.setPods(len(pp.pods.Pods))
+	pp.metrics.setPods(len(pp.addresses.Addresses))
 	pp.metrics.setExists(true)
 }
 
-func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSet {
-	pods := make(map[PodID]Address)
+func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) AddressSet {
+	addresses := make(map[ID]Address)
 	for _, subset := range endpoints.Subsets {
 		resolvedPort := pp.resolveTargetPort(subset)
 		for _, endpoint := range subset.Addresses {
@@ -458,9 +464,11 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 					}, "-"),
 					Namespace: endpoints.ObjectMeta.Namespace,
 				}
-				pods[id] = Address{
-					IP:   endpoint.IP,
-					Port: resolvedPort,
+
+				addresses[id] = Address{
+					IP:       endpoint.IP,
+					Port:     resolvedPort,
+					Identity: endpoints.Annotations[consts.RemoteGatewayIdentity],
 				}
 				continue
 			}
@@ -475,7 +483,7 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 					continue
 				}
 				ownerKind, ownerName := pp.k8sAPI.GetOwnerKindAndName(pod, false)
-				pods[id] = Address{
+				addresses[id] = Address{
 					IP:        endpoint.IP,
 					Port:      resolvedPort,
 					Pod:       pod,
@@ -485,9 +493,9 @@ func (pp *portPublisher) endpointsToAddresses(endpoints *corev1.Endpoints) PodSe
 			}
 		}
 	}
-	return PodSet{
-		Pods:   pods,
-		Labels: map[string]string{"service": endpoints.Name, "namespace": endpoints.Namespace},
+	return AddressSet{
+		Addresses: addresses,
+		Labels:    map[string]string{"service": endpoints.Name, "namespace": endpoints.Namespace},
 	}
 }
 
@@ -517,7 +525,7 @@ func (pp *portPublisher) updatePort(targetPort namedPort) {
 
 func (pp *portPublisher) noEndpoints(exists bool) {
 	pp.exists = exists
-	pp.pods = PodSet{}
+	pp.addresses = AddressSet{}
 	for _, listener := range pp.listeners {
 		listener.NoEndpoints(exists)
 	}
@@ -529,8 +537,8 @@ func (pp *portPublisher) noEndpoints(exists bool) {
 
 func (pp *portPublisher) subscribe(listener EndpointUpdateListener) {
 	if pp.exists {
-		if len(pp.pods.Pods) > 0 {
-			listener.Add(pp.pods)
+		if len(pp.addresses.Addresses) > 0 {
+			listener.Add(pp.addresses)
 		} else {
 			listener.NoEndpoints(true)
 		}
@@ -584,28 +592,28 @@ func getTargetPort(service *corev1.Service, port Port) namedPort {
 	return targetPort
 }
 
-func diffPods(oldPods, newPods PodSet) (add, remove PodSet) {
+func diffAddresses(oldAddresses, newAddresses AddressSet) (add, remove AddressSet) {
 	// TODO: this detects pods which have been added or removed, but does not
-	// detect pods which have been modified.  A modified pod should trigger
+	// detect addresses which have been modified.  A modified address should trigger
 	// an add of the new version.
-	addPods := make(map[PodID]Address)
-	removePods := make(map[PodID]Address)
-	for id, pod := range newPods.Pods {
-		if _, ok := oldPods.Pods[id]; !ok {
-			addPods[id] = pod
+	addAddesses := make(map[ID]Address)
+	removeAddresses := make(map[ID]Address)
+	for id, address := range newAddresses.Addresses {
+		if _, ok := oldAddresses.Addresses[id]; !ok {
+			addAddesses[id] = address
 		}
 	}
-	for id, pod := range oldPods.Pods {
-		if _, ok := newPods.Pods[id]; !ok {
-			removePods[id] = pod
+	for id, address := range oldAddresses.Addresses {
+		if _, ok := newAddresses.Addresses[id]; !ok {
+			removeAddresses[id] = address
 		}
 	}
-	add = PodSet{
-		Pods:   addPods,
-		Labels: newPods.Labels,
+	add = AddressSet{
+		Addresses: addAddesses,
+		Labels:    newAddresses.Labels,
 	}
-	remove = PodSet{
-		Pods: removePods,
+	remove = AddressSet{
+		Addresses: removeAddresses,
 	}
 	return
 }
diff --git a/controller/api/destination/watcher/ip_watcher.go b/controller/api/destination/watcher/ip_watcher.go
index a38aa1330..b0a45d017 100644
--- a/controller/api/destination/watcher/ip_watcher.go
+++ b/controller/api/destination/watcher/ip_watcher.go
@@ -29,7 +29,7 @@ type (
 
 		// At most one of service or pod may be non-zero.
 		service ServiceID
-		pod     PodSet
+		pod     AddressSet
 
 		listeners map[EndpointUpdateListener]Port
 		endpoints *EndpointsWatcher
@@ -150,7 +150,7 @@ func (iw *IPWatcher) addPod(obj interface{}) {
 		return
 	}
 	ss := iw.getOrNewServiceSubscriptions(pod.Status.PodIP)
-	ss.updatePod(iw.podToPodSet(pod))
+	ss.updatePod(iw.podToAddressSet(pod))
 }
 
 func (iw *IPWatcher) deletePod(obj interface{}) {
@@ -228,7 +228,7 @@ func (iw *IPWatcher) getOrNewServiceSubscriptions(clusterIP string) *serviceSubs
 				iw.log.Errorf("Pod IP conflict: %v, %v", objs[0], objs[1])
 			}
 			if len(pods) == 1 {
-				ss.pod = iw.podToPodSet(pods[0])
+				ss.pod = iw.podToAddressSet(pods[0])
 			}
 		}
 
@@ -244,14 +244,14 @@ func (iw *IPWatcher) getServiceSubscriptions(clusterIP string) (ss *serviceSubsc
 	return
 }
 
-func (iw *IPWatcher) podToPodSet(pod *corev1.Pod) PodSet {
+func (iw *IPWatcher) podToAddressSet(pod *corev1.Pod) AddressSet {
 	if pod.Spec.HostNetwork {
-		return PodSet{}
+		return AddressSet{}
 	}
 
 	ownerKind, ownerName := iw.k8sAPI.GetOwnerKindAndName(pod, true)
-	return PodSet{
-		Pods: map[PodID]Address{
+	return AddressSet{
+		Addresses: map[PodID]Address{
 			PodID{
 				Name:      pod.Name,
 				Namespace: pod.Namespace,
@@ -292,7 +292,7 @@ func (ss *serviceSubscriptions) updateService(service *corev1.Service) {
 			}
 		}
 		ss.service = id
-		ss.pod = PodSet{}
+		ss.pod = AddressSet{}
 	}
 }
 
@@ -309,13 +309,13 @@ func (ss *serviceSubscriptions) deleteService() {
 	ss.service = ServiceID{}
 }
 
-func (ss *serviceSubscriptions) updatePod(podSet PodSet) {
+func (ss *serviceSubscriptions) updatePod(podSet AddressSet) {
 	ss.Lock()
 	defer ss.Unlock()
 
 	for listener, port := range ss.listeners {
 		listener.NoEndpoints(true) // Clear out previous endpoints.
-		if len(podSet.Pods) != 0 {
+		if len(podSet.Addresses) != 0 {
 			podSetWithPort := withPort(podSet, port)
 			listener.Add(podSetWithPort)
 		}
@@ -330,7 +330,7 @@ func (ss *serviceSubscriptions) deletePod() {
 		listener.NoEndpoints(true) // Clear out previous endpoints.
 		listener.Add(singletonAddress(ss.clusterIP, port))
 	}
-	ss.pod = PodSet{}
+	ss.pod = AddressSet{}
 }
 
 func (ss *serviceSubscriptions) subscribe(port Port, listener EndpointUpdateListener) error {
@@ -342,7 +342,7 @@ func (ss *serviceSubscriptions) subscribe(port Port, listener EndpointUpdateList
 		if err != nil {
 			return err
 		}
-	} else if len(ss.pod.Pods) != 0 {
+	} else if len(ss.pod.Addresses) != 0 {
 		podSetWithPort := withPort(ss.pod, port)
 		listener.Add(podSetWithPort)
 	} else {
@@ -362,21 +362,21 @@ func (ss *serviceSubscriptions) unsubscribe(port Port, listener EndpointUpdateLi
 	delete(ss.listeners, listener)
 }
 
-func withPort(pods PodSet, port Port) PodSet {
-	wp := PodSet{
-		Pods:   map[PodID]Address{},
-		Labels: pods.Labels,
+func withPort(pods AddressSet, port Port) AddressSet {
+	wp := AddressSet{
+		Addresses: map[PodID]Address{},
+		Labels:    pods.Labels,
 	}
-	for id, pod := range pods.Pods {
-		pod.Port = port
-		wp.Pods[id] = pod
+	for id, addr := range pods.Addresses {
+		addr.Port = port
+		wp.Addresses[id] = addr
 	}
 	return wp
 }
 
-func singletonAddress(ip string, port Port) PodSet {
-	return PodSet{
-		Pods: map[PodID]Address{
+func singletonAddress(ip string, port Port) AddressSet {
+	return AddressSet{
+		Addresses: map[PodID]Address{
 			PodID{}: Address{
 				IP:   ip,
 				Port: port,
diff --git a/grafana/dashboards/authority.json b/grafana/dashboards/authority.json
index 08b6caf7f..9e8adedc6 100644
--- a/grafana/dashboards/authority.json
+++ b/grafana/dashboards/authority.json
@@ -1180,7 +1180,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1202,7 +1202,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/cronjob.json b/grafana/dashboards/cronjob.json
index 8d9a97eb9..0f7268e1c 100644
--- a/grafana/dashboards/cronjob.json
+++ b/grafana/dashboards/cronjob.json
@@ -2174,7 +2174,7 @@
     "templating": {
       "list": [
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2196,7 +2196,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2218,7 +2218,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
@@ -2240,7 +2240,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {},
           "datasource": "prometheus",
           "definition": "",
diff --git a/grafana/dashboards/daemonset.json b/grafana/dashboards/daemonset.json
index c11e634d0..0a86b717c 100644
--- a/grafana/dashboards/daemonset.json
+++ b/grafana/dashboards/daemonset.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/deployment.json b/grafana/dashboards/deployment.json
index 32185b3f0..08c5d83d7 100644
--- a/grafana/dashboards/deployment.json
+++ b/grafana/dashboards/deployment.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/health.json b/grafana/dashboards/health.json
index 203faf5a6..c613141db 100644
--- a/grafana/dashboards/health.json
+++ b/grafana/dashboards/health.json
@@ -2233,7 +2233,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2258,7 +2258,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2283,7 +2283,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/job.json b/grafana/dashboards/job.json
index 69bdabd5d..e2e268c07 100644
--- a/grafana/dashboards/job.json
+++ b/grafana/dashboards/job.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/namespace.json b/grafana/dashboards/namespace.json
index 04b82c2dc..3d6a459ab 100644
--- a/grafana/dashboards/namespace.json
+++ b/grafana/dashboards/namespace.json
@@ -893,7 +893,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{deployment!=\"\"}, namespace)",
@@ -915,7 +915,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{namespace=\"$namespace\"}, deployment)",
diff --git a/grafana/dashboards/pod.json b/grafana/dashboards/pod.json
index 93ffa54b0..1f99773c3 100644
--- a/grafana/dashboards/pod.json
+++ b/grafana/dashboards/pod.json
@@ -2121,7 +2121,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2143,7 +2143,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2165,7 +2165,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "label_values(process_start_time_seconds{namespace=\"$namespace\"}, deployment)",
@@ -2187,7 +2187,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -2212,7 +2212,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/grafana/dashboards/prometheus-benchmark.json b/grafana/dashboards/prometheus-benchmark.json
index 1a3d36423..c4911aeb5 100644
--- a/grafana/dashboards/prometheus-benchmark.json
+++ b/grafana/dashboards/prometheus-benchmark.json
@@ -3641,7 +3641,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -3663,7 +3663,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/replicaset.json b/grafana/dashboards/replicaset.json
index 59b56bb9b..9564224fc 100644
--- a/grafana/dashboards/replicaset.json
+++ b/grafana/dashboards/replicaset.json
@@ -2208,7 +2208,7 @@
     "templating": {
       "list": [
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "default",
             "value": "default"
@@ -2233,7 +2233,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "rs1",
             "value": "rs1"
@@ -2258,7 +2258,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "All",
             "value": "$__all"
@@ -2283,7 +2283,7 @@
           "useTags": false
         },
         {
-          "allValue": null,
+          "allValue": ".*",
           "current": {
             "text": "All",
             "value": "$__all"
diff --git a/grafana/dashboards/replicationcontroller.json b/grafana/dashboards/replicationcontroller.json
index 5b4389a69..36c271145 100644
--- a/grafana/dashboards/replicationcontroller.json
+++ b/grafana/dashboards/replicationcontroller.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/service.json b/grafana/dashboards/service.json
index dea9edc23..db517460f 100644
--- a/grafana/dashboards/service.json
+++ b/grafana/dashboards/service.json
@@ -1180,7 +1180,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1202,7 +1202,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -1224,7 +1224,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -1249,7 +1249,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/grafana/dashboards/statefulset.json b/grafana/dashboards/statefulset.json
index b9491a996..b6df517ec 100644
--- a/grafana/dashboards/statefulset.json
+++ b/grafana/dashboards/statefulset.json
@@ -2174,7 +2174,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2196,7 +2196,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2218,7 +2218,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
@@ -2240,7 +2240,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {},
         "datasource": "prometheus",
         "definition": "",
diff --git a/grafana/dashboards/top-line.json b/grafana/dashboards/top-line.json
index 6483a4773..d9181a2b6 100644
--- a/grafana/dashboards/top-line.json
+++ b/grafana/dashboards/top-line.json
@@ -999,7 +999,7 @@
   "templating": {
     "list": [
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
@@ -1024,7 +1024,7 @@
         "useTags": false
       },
       {
-        "allValue": null,
+        "allValue": ".*",
         "current": {
           "text": "All",
           "value": "$__all"
diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go
index 4107fed97..892a9e888 100644
--- a/pkg/k8s/k8s.go
+++ b/pkg/k8s/k8s.go
@@ -32,6 +32,12 @@ const (
 	l5dJob = "k8s_job"
 )
 
+type resourceName struct {
+	short  string
+	full   string
+	plural string
+}
+
 // AllResources is a sorted list of all resources defined as constants above.
 var AllResources = []string{
 	Authority,
@@ -65,6 +71,23 @@ var StatAllResourceTypes = []string{
 	ReplicaSet,
 }
 
+var resourceNames = []resourceName{
+	{"au", "authority", "authorities"},
+	{"cj", "cronjob", "cronjobs"},
+	{"ds", "daemonset", "daemonsets"},
+	{"deploy", "deployment", "deployments"},
+	{"job", "job", "jobs"},
+	{"ns", "namespace", "namespaces"},
+	{"po", "pod", "pods"},
+	{"rc", "replicationcontroller", "replicationcontrollers"},
+	{"rs", "replicaset", "replicasets"},
+	{"svc", "service", "services"},
+	{"sp", "serviceprofile", "serviceprofiles"},
+	{"sts", "statefulset", "statefulsets"},
+	{"ts", "trafficsplit", "trafficsplits"},
+	{"all", "all", "all"},
+}
+
 // GetConfig returns kubernetes config based on the current environment.
 // If fpath is provided, loads configuration from that file. Otherwise,
 // GetConfig uses default strategy to load configuration from $KUBECONFIG,
@@ -84,37 +107,23 @@ func GetConfig(fpath, kubeContext string) (*rest.Config, error) {
 // This works based on https://github.com/kubernetes/kubernetes/blob/63ffb1995b292be0a1e9ebde6216b83fc79dd988/pkg/kubectl/kubectl.go#L39
 // This also works for non-k8s resources, e.g. authorities
 func CanonicalResourceNameFromFriendlyName(friendlyName string) (string, error) {
-	switch friendlyName {
-	case "au", "authority", "authorities":
-		return Authority, nil
-	case "cj", "cronjob", "cronjobs":
-		return CronJob, nil
-	case "ds", "daemonset", "daemonsets":
-		return DaemonSet, nil
-	case "deploy", "deployment", "deployments":
-		return Deployment, nil
-	case "job", "jobs":
-		return Job, nil
-	case "ns", "namespace", "namespaces":
-		return Namespace, nil
-	case "po", "pod", "pods":
-		return Pod, nil
-	case "rc", "replicationcontroller", "replicationcontrollers":
-		return ReplicationController, nil
-	case "rs", "replicaset", "replicasets":
-		return ReplicaSet, nil
-	case "svc", "service", "services":
-		return Service, nil
-	case "sp", "serviceprofile", "serviceprofiles":
-		return ServiceProfile, nil
-	case "sts", "statefulset", "statefulsets":
-		return StatefulSet, nil
-	case "ts", "trafficsplit", "trafficsplits":
-		return TrafficSplit, nil
-	case "all":
-		return All, nil
+	for _, name := range resourceNames {
+		if friendlyName == name.short || friendlyName == name.full || friendlyName == name.plural {
+			return name.full, nil
+		}
 	}
+	return "", fmt.Errorf("cannot find Kubernetes canonical name from friendly name [%s]", friendlyName)
+}
 
+// PluralResourceNameFromFriendlyName returns a pluralized canonical name from common shorthands used in command line tools.
+// This works based on https://github.com/kubernetes/kubernetes/blob/63ffb1995b292be0a1e9ebde6216b83fc79dd988/pkg/kubectl/kubectl.go#L39
+// This also works for non-k8s resources, e.g. authorities
+func PluralResourceNameFromFriendlyName(friendlyName string) (string, error) {
+	for _, name := range resourceNames {
+		if friendlyName == name.short || friendlyName == name.full || friendlyName == name.plural {
+			return name.plural, nil
+		}
+	}
 	return "", fmt.Errorf("cannot find Kubernetes canonical name from friendly name [%s]", friendlyName)
 }
 
diff --git a/pkg/k8s/labels.go b/pkg/k8s/labels.go
index fbff494de..4a59499bb 100644
--- a/pkg/k8s/labels.go
+++ b/pkg/k8s/labels.go
@@ -374,6 +374,9 @@ const (
 	// in cases we detect a change in a remote gateway
 	RemoteGatewayResourceVersionAnnotation = SvcMirrorPrefix + "/remote-gateway-resource-version"
 
+	// RemoteGatewayIdentity follows the same kind of logic as RemoteGatewayNameLabel
+	RemoteGatewayIdentity = SvcMirrorPrefix + "/remote-gateway-identity"
+
 	// ConfigKeyName is the key in the secret that stores the kubeconfig needed to connect
 	// to a remote cluster
 	ConfigKeyName = "kubeconfig"
diff --git a/pkg/smimetrics/client.go b/pkg/smimetrics/client.go
new file mode 100644
index 000000000..b4c0ae367
--- /dev/null
+++ b/pkg/smimetrics/client.go
@@ -0,0 +1,201 @@
+package smimetrics
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+
+	"github.com/linkerd/linkerd2/pkg/k8s"
+	log "github.com/sirupsen/logrus"
+)
+
+type (
+	// TrafficMetricsList is a list of TrafficMetrics objects for a Resource.
+	TrafficMetricsList struct {
+		Resource Resource         `json:"resource"`
+		Items    []TrafficMetrics `json:"items"`
+	}
+
+	// Resource names a Kubernetes resource.  If Name is blank, this refers to
+	// a resource kind.  Otherwise, it refers to a specific named resource.
+	Resource struct {
+		Kind      string `json:"kind"`
+		Name      string `json:"name"`
+		Namespace string `json:"namespace"`
+	}
+
+	// TrafficMetrics is a collection of metrics for a Resource.
+	TrafficMetrics struct {
+		Window   string   `json:"window"`
+		Resource Resource `json:"resource"`
+		Edge     Edge     `json:"edge"`
+		Metrics  []Metric `json:"metrics"`
+	}
+
+	// Edge indicates if the associated metrics are measured on the inbound
+	// or outbound side and which resource is the peer.
+	Edge struct {
+		Direction string   `json:"direction"`
+		Resource  Resource `json:"resource"`
+	}
+
+	// Metric is an individual traffic metric.
+	Metric struct {
+		Name  string `json:"name"`
+		Unit  string `json:"unit"`
+		Value string `json:"value"`
+	}
+
+	// Status is an API response that is returned when the operation is not
+	// successful.
+	Status struct {
+		Kind    string `json:"kind"`
+		Status  string `json:"status"`
+		Message string `json:"message"`
+	}
+)
+
+const apiBase = "/apis/metrics.smi-spec.io/v1alpha1"
+
+func (r Resource) String() string {
+	return fmt.Sprintf("%s.%s", r.Name, r.Namespace)
+}
+
+// GetTrafficMetrics returns the inbound traffic metrics for a specific named
+// resource.
+func GetTrafficMetrics(k8sAPI *k8s.KubernetesAPI, namespace, kind, name string, params map[string]string) (*TrafficMetrics, error) {
+	ns := ""
+	if namespace != "" {
+		ns = "/namespaces/" + namespace
+	}
+	path := fmt.Sprintf("%s%s/%s/%s", apiBase, ns, kind, name)
+
+	bytes, err := getMetricsResponse(k8sAPI, path, params)
+	if err != nil {
+		return nil, err
+	}
+
+	return parseTrafficMetrics(bytes)
+}
+
+// GetTrafficMetricsList returns the inbound traffic metrics for all resources
+// of a given kind.
+func GetTrafficMetricsList(k8sAPI *k8s.KubernetesAPI, namespace, kind string, params map[string]string) (*TrafficMetricsList, error) {
+	ns := ""
+	if namespace != "" {
+		ns = "/namespaces/" + namespace
+	}
+	path := fmt.Sprintf("%s%s/%s", apiBase, ns, kind)
+
+	bytes, err := getMetricsResponse(k8sAPI, path, params)
+	if err != nil {
+		return nil, err
+	}
+
+	return parseTrafficMetricsList(bytes)
+}
+
+// GetTrafficMetricsEdgesList returns the edge traffic metrics for a specific
+// named resource.
+func GetTrafficMetricsEdgesList(k8sAPI *k8s.KubernetesAPI, namespace, kind, name string, params map[string]string) (*TrafficMetricsList, error) {
+	ns := ""
+	if namespace != "" {
+		ns = "/namespaces/" + namespace
+	}
+	path := fmt.Sprintf("%s%s/%s/%s/edges", apiBase, ns, kind, name)
+
+	bytes, err := getMetricsResponse(k8sAPI, path, params)
+	if err != nil {
+		return nil, err
+	}
+
+	return parseTrafficMetricsList(bytes)
+}
+
+func getMetricsResponse(k8sAPI *k8s.KubernetesAPI, path string, params map[string]string) ([]byte, error) {
+	client, err := k8sAPI.NewClient()
+	if err != nil {
+		return nil, err
+	}
+
+	url, err := url.Parse(k8sAPI.Host)
+	if err != nil {
+		return nil, err
+	}
+
+	url.Path = path
+
+	for k, v := range params {
+		url.Query().Add(k, v)
+	}
+
+	log.Debugf("Requesting %s", url)
+
+	httpReq, err := http.NewRequest(
+		http.MethodGet,
+		url.String(),
+		nil,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	httpRsp, err := client.Do(httpReq)
+	if err != nil {
+		log.Debugf("Error invoking [%s]: %v", url, err)
+		return nil, err
+	}
+	defer httpRsp.Body.Close()
+
+	log.Debugf("Response from [%s] had headers: %v", url, httpRsp.Header)
+
+	return ioutil.ReadAll(httpRsp.Body)
+}
+
+func handleStatusResponse(bytes []byte) error {
+	var status Status
+	json.Unmarshal(bytes, &status)
+	if status.Kind == "Status" {
+		return errors.New(status.Message)
+	}
+	return nil
+}
+
+func parseTrafficMetrics(bytes []byte) (*TrafficMetrics, error) {
+	err := handleStatusResponse(bytes)
+	if err != nil {
+		return nil, err
+	}
+
+	var metrics TrafficMetrics
+	err = json.Unmarshal(bytes, &metrics)
+	if err != nil {
+		log.Errorf("Failed to decode response as TrafficMetrics [%s]: %v", string(bytes), err)
+		return nil, errors.New(string(bytes))
+	}
+
+	log.Debugf("Parsed TrafficMetrics: %+v", metrics)
+
+	return &metrics, nil
+}
+
+func parseTrafficMetricsList(bytes []byte) (*TrafficMetricsList, error) {
+	err := handleStatusResponse(bytes)
+	if err != nil {
+		return nil, err
+	}
+
+	var metrics TrafficMetricsList
+	err = json.Unmarshal(bytes, &metrics)
+	if err != nil {
+		log.Errorf("Failed to decode response as TrafficMetricsList [%s]: %v", string(bytes), err)
+		return nil, errors.New(string(bytes))
+	}
+
+	log.Debugf("Parsed TrafficMetricsList: %+v", metrics)
+
+	return &metrics, nil
+}


Test patch:
diff --git a/controller/api/destination/endpoint_translator_test.go b/controller/api/destination/endpoint_translator_test.go
index 7c57b1e45..3f5e22b38 100644
--- a/controller/api/destination/endpoint_translator_test.go
+++ b/controller/api/destination/endpoint_translator_test.go
@@ -1,8 +1,10 @@
 package destination
 
 import (
+	"fmt"
 	"reflect"
 	"sort"
+	"strings"
 	"testing"
 
 	pb "github.com/linkerd/linkerd2-proxy-api/go/destination"
@@ -92,6 +94,17 @@ var (
 			},
 		},
 	}
+
+	remoteGatewayWithNoTLS = watcher.Address{
+		IP:   "1.1.1.1",
+		Port: 1,
+	}
+
+	remoteGatewayWithTLS = watcher.Address{
+		IP:       "1.1.1.2",
+		Port:     2,
+		Identity: "some-identity",
+	}
 )
 
 func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointTranslator) {
@@ -99,7 +112,7 @@ func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointT
 	translator := newEndpointTranslator(
 		"linkerd",
 		"trust.domain",
-		false,
+		true,
 		"service-name.service-ns",
 		mockGetServer,
 		logging.WithField("test", t.Name()),
@@ -107,12 +120,77 @@ func makeEndpointTranslator(t *testing.T) (*mockDestinationGetServer, *endpointT
 	return mockGetServer, translator
 }
 
-func TestEndpointTranslator(t *testing.T) {
+func TestEndpointTranslatorForRemoteGateways(t *testing.T) {
+	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS))
+		translator.Remove(mkAddressSetForServices(remoteGatewayWithTLS))
+
+		expectedNumUpdates := 2
+		actualNumUpdates := len(mockGetServer.updatesReceived)
+		if actualNumUpdates != expectedNumUpdates {
+			t.Fatalf("Expecting [%d] updates, got [%d]. Updates: %v", expectedNumUpdates, actualNumUpdates, mockGetServer.updatesReceived)
+		}
+	})
+
+	t.Run("Sends TlsIdentity when enabled", func(t *testing.T) {
+		expectedTLSIdentity := &pb.TlsIdentity_DnsLikeIdentity{
+			Name: "some-identity",
+		}
+
+		expectedProtocolHint := &pb.ProtocolHint{
+			Protocol: &pb.ProtocolHint_H2_{
+				H2: &pb.ProtocolHint_H2{},
+			},
+		}
+
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithTLS))
+
+		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+
+		actualTLSIdentity := addrs[0].GetTlsIdentity().GetDnsLikeIdentity()
+		if !reflect.DeepEqual(actualTLSIdentity, expectedTLSIdentity) {
+			t.Fatalf("Expected TlsIdentity to be [%v] but was [%v]", expectedTLSIdentity, actualTLSIdentity)
+		}
+
+		actualProtocolHint := addrs[0].GetProtocolHint()
+		if !reflect.DeepEqual(actualProtocolHint, expectedProtocolHint) {
+			t.Fatalf("Expected ProtoclHint to be [%v] but was [%v]", expectedProtocolHint, actualProtocolHint)
+		}
+	})
+
+	t.Run("Does not send TlsIdentity when not present", func(t *testing.T) {
+		mockGetServer, translator := makeEndpointTranslator(t)
+
+		translator.Add(mkAddressSetForServices(remoteGatewayWithNoTLS))
+
+		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
+		if len(addrs) != 1 {
+			t.Fatalf("Expected [1] address returned, got %v", addrs)
+		}
+
+		if addrs[0].TlsIdentity != nil {
+			t.Fatalf("Expected no TlsIdentity to be sent, but got [%v]", addrs[0].TlsIdentity)
+		}
+		if addrs[0].ProtocolHint != nil {
+			t.Fatalf("Expected no ProtocolHint to be sent, but got [%v]", addrs[0].TlsIdentity)
+		}
+	})
+
+}
+
+func TestEndpointTranslatorForPods(t *testing.T) {
 	t.Run("Sends one update for add and another for remove", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
-		translator.Remove(mkPodSet(tlsOptionalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
+		translator.Remove(mkAddressSetForPods(tlsOptionalPod))
 
 		expectedNumUpdates := 2
 		actualNumUpdates := len(mockGetServer.updatesReceived)
@@ -124,8 +202,8 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Sends addresses as removed or added", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod, tlsOptionalPod))
-		translator.Remove(mkPodSet(tlsDisabledPod))
+		translator.Add(mkAddressSetForPods(normalPod, tlsOptionalPod))
+		translator.Remove(mkAddressSetForPods(tlsDisabledPod))
 
 		addressesAdded := mockGetServer.updatesReceived[0].GetAdd().Addrs
 		actualNumberOfAdded := len(addressesAdded)
@@ -152,7 +230,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Sends metric labels with added addresses", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
 
 		actualGlobalMetricLabels := mockGetServer.updatesReceived[0].GetAdd().MetricLabels
 		expectedGlobalMetricLabels := map[string]string{"namespace": "service-ns", "service": "service-name"}
@@ -179,7 +257,7 @@ func TestEndpointTranslator(t *testing.T) {
 
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(normalPod))
+		translator.Add(mkAddressSetForPods(normalPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -195,7 +273,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity for non-default identity-modes", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(tlsOptionalPod))
+		translator.Add(mkAddressSetForPods(tlsOptionalPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -210,7 +288,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity for other meshes", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(otherMeshPod))
+		translator.Add(mkAddressSetForPods(otherMeshPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -225,7 +303,7 @@ func TestEndpointTranslator(t *testing.T) {
 	t.Run("Does not send TlsIdentity when not enabled", func(t *testing.T) {
 		mockGetServer, translator := makeEndpointTranslator(t)
 
-		translator.Add(mkPodSet(tlsDisabledPod))
+		translator.Add(mkAddressSetForPods(tlsDisabledPod))
 
 		addrs := mockGetServer.updatesReceived[0].GetAdd().GetAddrs()
 		if len(addrs) != 1 {
@@ -238,14 +316,33 @@ func TestEndpointTranslator(t *testing.T) {
 	})
 }
 
-func mkPodSet(pods ...watcher.Address) watcher.PodSet {
-	set := watcher.PodSet{
-		Pods:   make(map[watcher.PodID]watcher.Address),
-		Labels: map[string]string{"service": "service-name", "namespace": "service-ns"},
+func mkAddressSetForServices(gatewayAddresses ...watcher.Address) watcher.AddressSet {
+	set := watcher.AddressSet{
+		Addresses: make(map[watcher.ServiceID]watcher.Address),
+		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
+	}
+	for _, a := range gatewayAddresses {
+		a := a // pin
+
+		id := watcher.ServiceID{
+			Name: strings.Join([]string{
+				a.IP,
+				fmt.Sprint(a.Port),
+			}, "-"),
+		}
+		set.Addresses[id] = a
+	}
+	return set
+}
+
+func mkAddressSetForPods(podAddresses ...watcher.Address) watcher.AddressSet {
+	set := watcher.AddressSet{
+		Addresses: make(map[watcher.PodID]watcher.Address),
+		Labels:    map[string]string{"service": "service-name", "namespace": "service-ns"},
 	}
-	for _, p := range pods {
+	for _, p := range podAddresses {
 		id := watcher.PodID{Name: p.Pod.Name, Namespace: p.Pod.Namespace}
-		set.Pods[id] = p
+		set.Addresses[id] = p
 	}
 	return set
 }
diff --git a/controller/api/destination/watcher/endpoints_watcher_test.go b/controller/api/destination/watcher/endpoints_watcher_test.go
index 6798ca2ca..07d566848 100644
--- a/controller/api/destination/watcher/endpoints_watcher_test.go
+++ b/controller/api/destination/watcher/endpoints_watcher_test.go
@@ -27,17 +27,21 @@ func newBufferingEndpointListener() *bufferingEndpointListener {
 }
 
 func addressString(address Address) string {
-	return fmt.Sprintf("%s:%d", address.IP, address.Port)
+	addressString := fmt.Sprintf("%s:%d", address.IP, address.Port)
+	if address.Identity != "" {
+		addressString = fmt.Sprintf("%s/%s", addressString, address.Identity)
+	}
+	return addressString
 }
 
-func (bel *bufferingEndpointListener) Add(set PodSet) {
-	for _, address := range set.Pods {
+func (bel *bufferingEndpointListener) Add(set AddressSet) {
+	for _, address := range set.Addresses {
 		bel.added = append(bel.added, addressString(address))
 	}
 }
 
-func (bel *bufferingEndpointListener) Remove(set PodSet) {
-	for _, address := range set.Pods {
+func (bel *bufferingEndpointListener) Remove(set AddressSet) {
+	for _, address := range set.Addresses {
 		bel.removed = append(bel.removed, addressString(address))
 	}
 }
@@ -271,7 +275,7 @@ status:
 			expectedError:                    false,
 		},
 		{
-			serviceType: "local services with missing pods",
+			serviceType: "local services with missing addresses",
 			k8sConfigs: []string{`
 apiVersion: v1
 kind: Service
@@ -615,5 +619,165 @@ status:
 		})
 
 	}
+}
+
+func TestEndpointsWatcherServiceMirrors(t *testing.T) {
+	for _, tt := range []struct {
+		serviceType                      string
+		k8sConfigs                       []string
+		id                               ServiceID
+		hostname                         string
+		port                             Port
+		expectedAddresses                []string
+		expectedNoEndpoints              bool
+		expectedNoEndpointsServiceExists bool
+	}{
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-gateway-identity: "gateway-identity-1"
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 8989`,
+			},
+			serviceType: "mirrored service with identity",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        8989,
+			expectedAddresses: []string{
+				"172.17.0.12:8989/gateway-identity-1",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 8989`,
+			},
+			serviceType: "mirrored service without identity",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        8989,
+			expectedAddresses: []string{
+				"172.17.0.12:8989",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+
+		{
+			k8sConfigs: []string{`
+apiVersion: v1
+kind: Service
+metadata:
+  name: name1-remote
+  namespace: ns
+spec:
+  type: LoadBalancer
+  ports:
+  - port: 8989`,
+				`
+apiVersion: v1
+kind: Endpoints
+metadata:
+  name: name1-remote
+  namespace: ns
+  annotations:
+    mirror.linkerd.io/remote-gateway-identity: "gateway-identity-1"
+    mirror.linkerd.io/remote-svc-fq-name: "name1-remote-fq"
+  labels:
+    mirror.linkerd.io/mirrored-service: "true"
+subsets:
+- addresses:
+  - ip: 172.17.0.12
+  ports:
+  - port: 9999`,
+			},
+			serviceType: "mirrored service with remapped port in endpoints",
+			id:          ServiceID{Name: "name1-remote", Namespace: "ns"},
+			port:        9999,
+			expectedAddresses: []string{
+				"172.17.0.12:9999/gateway-identity-1",
+			},
+			expectedNoEndpoints:              false,
+			expectedNoEndpointsServiceExists: false,
+		},
+	} {
+		tt := tt // pin
+		t.Run("subscribes listener to "+tt.serviceType, func(t *testing.T) {
+			k8sAPI, err := k8s.NewFakeAPI(tt.k8sConfigs...)
+			if err != nil {
+				t.Fatalf("NewFakeAPI returned an error: %s", err)
+			}
+
+			watcher := NewEndpointsWatcher(k8sAPI, logging.WithField("test", t.Name()))
+
+			k8sAPI.Sync(nil)
+
+			listener := newBufferingEndpointListener()
+
+			err = watcher.Subscribe(tt.id, tt.port, tt.hostname, listener)
+
+			if err != nil {
+				t.Fatalf("NewFakeAPI returned an error: %s", err)
+			}
+
+			actualAddresses := make([]string, 0)
+			actualAddresses = append(actualAddresses, listener.added...)
+			sort.Strings(actualAddresses)
+
+			testCompare(t, tt.expectedAddresses, actualAddresses)
+
+			if listener.noEndpointsCalled != tt.expectedNoEndpoints {
+				t.Fatalf("Expected noEndpointsCalled to be [%t], got [%t]",
+					tt.expectedNoEndpoints, listener.noEndpointsCalled)
+			}
 
+			if listener.noEndpointsExists != tt.expectedNoEndpointsServiceExists {
+				t.Fatalf("Expected noEndpointsExists to be [%t], got [%t]",
+					tt.expectedNoEndpointsServiceExists, listener.noEndpointsExists)
+			}
+		})
+	}
 }
diff --git a/controller/api/destination/watcher/ip_watcher_test.go b/controller/api/destination/watcher/ip_watcher_test.go
index 2e4218088..17207a2b9 100644
--- a/controller/api/destination/watcher/ip_watcher_test.go
+++ b/controller/api/destination/watcher/ip_watcher_test.go
@@ -531,7 +531,7 @@ status:
 		deletingPod    bool
 	}{
 		{
-			description:    "can delete pods",
+			description:    "can delete addresses",
 			k8sConfigs:     podK8sConfig,
 			host:           "172.17.0.12",
 			port:           8989,
@@ -539,7 +539,7 @@ status:
 			deletingPod:    true,
 		},
 		{
-			description:    "can delete pods wrapped in a DeletedFinalStateUnknown",
+			description:    "can delete addresses wrapped in a DeletedFinalStateUnknown",
 			k8sConfigs:     podK8sConfig,
 			host:           "172.17.0.12",
 			port:           8989,