Repository: iter8-tools/iter8
Pull request #1338 (2022-09-19 17:23:41)

read abn task
Signed-off-by: Michael Kalantar <kalantar@us.ibm.com>


kalantar: Output of (text) report now shows track names if known:

```sh
$ iter8 k launch -l trace --noDownload --set abnmetrics.application=default/backend --set "tasks={abnmetrics,assess}" --set runner=cronjob --set cronjobSchedule="*/1 * * * *"

Experiment summary:
*******************

  Experiment completed: true
  No task failures: true
  Total number of tasks: 1
  Number of completed tasks: 1

Latest observed values for metrics:
***********************************

  Metric                   | default | candidate
  -------                  | -----
  abn/sample_metric/count  | 238.00 | 260.00
  abn/sample_metric/max    | 100.00 | 100.00
  abn/sample_metric/mean   | 47.58  | 49.36
  abn/sample_metric/min    | 0.00   | 0.00
  abn/sample_metric/stddev | 30.48  | 27.79
```

sriumcp: @kalantar Could you also provide a sample html report output?
kalantar: > @kalantar Could you also provide a sample html report output?

![image](https://user-images.githubusercontent.com/4826865/191598836-1b91872b-34a3-4972-92ec-f55e8ef98d63.png)


Patch:
diff --git a/abn/application/application.go b/abn/application/application.go
index 41dfe01d..56ca4dd1 100644
--- a/abn/application/application.go
+++ b/abn/application/application.go
@@ -9,6 +9,7 @@ import (
 	"strings"
 
 	"github.com/iter8-tools/iter8/base/log"
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 )
 
 // Application is an application observed in a kubernetes cluster
@@ -50,7 +51,7 @@ func (a *Application) GetVersion(version string, allowNew bool) (*Version, bool)
 		if allowNew {
 			log.Logger.Debugf("GetVersion no data found; returning %+v", v)
 			v = &Version{
-				Metrics: map[string]*SummaryMetric{},
+				Metrics: map[string]*summarymetrics.SummaryMetric{},
 			}
 			a.Versions[version] = v
 			return v, true
@@ -79,7 +80,7 @@ func (a *Application) UnmarshalJSON(data []byte) error {
 	}
 	for _, v := range a.Versions {
 		if v.Metrics == nil {
-			v.Metrics = map[string]*SummaryMetric{}
+			v.Metrics = map[string]*summarymetrics.SummaryMetric{}
 		}
 	}
 
diff --git a/abn/application/version.go b/abn/application/version.go
index 2722ca92..8ab7c92c 100644
--- a/abn/application/version.go
+++ b/abn/application/version.go
@@ -5,21 +5,23 @@ package application
 import (
 	"fmt"
 	"strings"
+
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 )
 
 // Version is information about versions of an application in a Kubernetes cluster
 type Version struct {
 	// List of (summary) metrics for a version
-	Metrics map[string]*SummaryMetric `json:"metrics" yaml:"metrics"`
+	Metrics map[string]*summarymetrics.SummaryMetric `json:"metrics" yaml:"metrics"`
 }
 
 // GetMetric returns a metric from the list of metrics associated with a version
 // If no metric is present for a given name, a new one is created
-func (v *Version) GetMetric(metric string, allowNew bool) (*SummaryMetric, bool) {
+func (v *Version) GetMetric(metric string, allowNew bool) (*summarymetrics.SummaryMetric, bool) {
 	m, ok := v.Metrics[metric]
 	if !ok {
 		if allowNew {
-			m := EmptySummaryMetric()
+			m := summarymetrics.EmptySummaryMetric()
 			v.Metrics[metric] = m
 			return m, true
 		}
diff --git a/abn/core/service.go b/abn/core/service.go
index 643169be..8fd4576b 100644
--- a/abn/core/service.go
+++ b/abn/core/service.go
@@ -5,12 +5,10 @@ package core
 
 import (
 	"context"
-	"flag"
 	"fmt"
 	"net"
 	"os"
-	"os/signal"
-	"syscall"
+	"sync"
 
 	pb "github.com/iter8-tools/iter8/abn/grpc"
 	"github.com/iter8-tools/iter8/abn/k8sclient"
@@ -20,7 +18,6 @@ import (
 	"google.golang.org/grpc"
 	"google.golang.org/protobuf/types/known/emptypb"
 
-	"helm.sh/helm/v3/pkg/cli"
 	// auth package is necessary to enable authentication with various cloud vendors
 	_ "k8s.io/client-go/plugin/pkg/client/auth"
 )
@@ -30,14 +27,8 @@ const (
 	watcherConfigEnv = "WATCHER_CONFIG"
 )
 
-var (
-	// port the service listens on
-	port = flag.Int("port", 50051, "The server port")
-)
-
 // Start is entry point to configure services and start them
-func Start() {
-	k8sclient.Client = *k8sclient.NewKubeClient(cli.New())
+func Start(port int, stopCh chan struct{}) error {
 	if err := k8sclient.Client.Initialize(); err != nil {
 		log.Logger.WithStackTrace("unable to initialize k8s client").Fatal(err)
 	}
@@ -48,21 +39,17 @@ func Start() {
 		log.Logger.Fatal("configuation file is required")
 	}
 
-	stopCh := make(chan struct{})
-
 	// set up resource watching as defined by config
 	c := readConfig(abnConfigFile)
 	w := watcher.NewIter8Watcher(c.Resources, c.Namespaces)
+
+	// start watchers
 	go w.Start(stopCh)
 
 	// launch gRPC server to respond to frontend requests
-	go launchGRPCServer([]grpc.ServerOption{})
+	go launchGRPCServer(port, []grpc.ServerOption{}, stopCh)
 
-	sigCh := make(chan os.Signal, 1)
-	signal.Notify(sigCh, syscall.SIGTERM, os.Interrupt)
-
-	<-sigCh
-	close(stopCh)
+	return nil
 }
 
 // newServer returns a new gRPC server
@@ -114,16 +101,29 @@ func (server *abnServer) GetApplicationData(ctx context.Context, metricReqMsg *p
 }
 
 // launchGRPCServer starts gRPC server
-func launchGRPCServer(opts []grpc.ServerOption) {
-	lis, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", *port))
+func launchGRPCServer(port int, opts []grpc.ServerOption, stopCh chan struct{}) {
+	log.Logger.Tracef("starting gRPC service on port %d", port)
+
+	lis, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
 	if err != nil {
 		log.Logger.WithError(err).Fatal("failed to listen")
 	}
 
 	grpcServer := grpc.NewServer(opts...)
 	pb.RegisterABNServer(grpcServer, newServer())
+
+	wg := sync.WaitGroup{}
+	wg.Add(1)
+	go func() {
+		<-stopCh
+		log.Logger.Warnf("stop channel closed, shutting down")
+		grpcServer.GracefulStop()
+	}()
+
 	err = grpcServer.Serve(lis)
 	if err != nil {
 		log.Logger.WithError(err).Fatal("failed to start service")
 	}
+	wg.Wait()
+	log.Logger.Trace("service shutdown")
 }
diff --git a/abn/k8sclient/k8sclient.go b/abn/k8sclient/k8sclient.go
index 2adad8d0..f48fde08 100644
--- a/abn/k8sclient/k8sclient.go
+++ b/abn/k8sclient/k8sclient.go
@@ -14,7 +14,7 @@ import (
 )
 
 // Client is a global variable. Before use, it must be assigned and initialized
-var Client KubeClient
+var Client = *NewKubeClient(cli.New())
 
 // KubeClient embeds Kube configuration, and
 // enables interaction with a Kubernetes cluster through Kube APIs
diff --git a/abn/watcher/doc.go b/abn/watcher/doc.go
index c6006c6c..9d14afb0 100644
--- a/abn/watcher/doc.go
+++ b/abn/watcher/doc.go
@@ -6,10 +6,7 @@ package watcher
 //  - label 'iter8.tools/abn' set to true indicating the resource should be inspected further
 //  - label 'app.kubernetes.io/name' identifying the name of the application to which the resource belongs
 //  - label 'app.kubernetes.io/version' identifying the name of the version  to which the resource belongs
-//
-// It may also include the following metadata:
-//  - annotation 'iter8.tools/ready' indicating the version is ready to receive traffic (default: false)
-//  - annotation 'iter8.tools/track' defining the track label that should be associated with the version (no track when not present)
+//  - label 'iter8.tools/track' defining the track label that should be associated with the version (no track when not present)
 
 // A track is a user assigned identifier. When the Iter8 A/N(/n) service is used to lookup versions,
 // the track identifier is returned (instead of a version name). The caller can use the the track
diff --git a/abn/watcher/handler.go b/abn/watcher/handler.go
index 785e9678..29e1891e 100644
--- a/abn/watcher/handler.go
+++ b/abn/watcher/handler.go
@@ -40,6 +40,8 @@ func handle(w watchedObject, resourceTypes []schema.GroupVersionResource, inform
 	application, _ := w.getNamespacedName()
 	namespace := w.getNamespace()
 	name, _ := w.getName()
+	version, _ := w.getVersion()
+	log.Logger.Tracef("handle called for %s (%s)", application, version)
 
 	applicationObjs := getApplicationObjects(namespace, name, resourceTypes, informerFactories)
 	// there is at least one object (w)
@@ -47,6 +49,7 @@ func handle(w watchedObject, resourceTypes []schema.GroupVersionResource, inform
 	a, _ := abnapp.Applications.Read(application)
 
 	abnapp.Applications.Lock(application)
+	defer abnapp.Applications.Unlock(application)
 	// clear a.Tracks, a.Versions[*].Track
 	// this is necessary because  we keep old versions in memory
 	a.ClearTracks()
@@ -59,7 +62,6 @@ func handle(w watchedObject, resourceTypes []schema.GroupVersionResource, inform
 			a.Tracks[track] = version
 		}
 	}
-	abnapp.Applications.Unlock(application)
 }
 
 // getApplicationObjects gets all the objects related to the application based on label app.kubernetes.io/name
diff --git a/action/report/common.go b/action/report/common.go
index aa0dc96e..49cf4770 100644
--- a/action/report/common.go
+++ b/action/report/common.go
@@ -19,11 +19,21 @@ type Reporter struct {
 // and also for scalar metrics referenced in SLOs.
 func (r *Reporter) SortedScalarAndSLOMetrics() []string {
 	keys := []string{}
-	// add scalar metrics referenced in MetricsInfo
+	// add scalar and summary metrics referenced in MetricsInfo
 	for k, mm := range r.Result.Insights.MetricsInfo {
 		if mm.Type == base.CounterMetricType || mm.Type == base.GaugeMetricType {
 			keys = append(keys, k)
 		}
+		if mm.Type == base.SummaryMetricType {
+			for _, agg := range []base.AggregationType{
+				base.CountAggregator,
+				base.MeanAggregator,
+				base.StdDevAggregator,
+				base.MinAggregator,
+				base.MaxAggregator} {
+				keys = append(keys, k+"/"+string(agg))
+			}
+		}
 	}
 	// also add metrics referenced in SLOs
 	// only scalar metrics can feature in SLOs (for now)
diff --git a/action/report/htmlreport.tpl b/action/report/htmlreport.tpl
index 8c66e656..b8b13d63 100644
--- a/action/report/htmlreport.tpl
+++ b/action/report/htmlreport.tpl
@@ -70,7 +70,7 @@
                 <th scope="col">SLO Conditions</th>
                 {{- if ge .Result.Insights.NumVersions 2 }}
                 {{- range until .Result.Insights.NumVersions }}
-                <th scope="col" class="text-center">Version {{ . }} </th>
+                <th scope="col" class="text-center">{{ $.Result.Insights.TrackVersionStr . }}</th>
                 {{- end}}              
                 {{- else }} 
                 <th scope="col" class="text-center">Satisfied</th>
@@ -154,7 +154,7 @@
                 <th scope="col">Metric</th>
                 {{- if ge .Result.Insights.NumVersions 2 }}
                 {{- range until .Result.Insights.NumVersions }}
-                <th scope="col" class="text-center">Version {{ . }} </th>
+                <th scope="col" class="text-center">{{ $.Result.Insights.TrackVersionStr . }}</th>
                 {{- end}}              
                 {{- else }} 
                 <th scope="col" class="text-center">Value</th>
diff --git a/action/report/textreporter.go b/action/report/textreporter.go
index c1a811bb..87abd697 100644
--- a/action/report/textreporter.go
+++ b/action/report/textreporter.go
@@ -81,19 +81,28 @@ func (tr *TextReporter) getSLOStrText(i int, upper bool) (string, error) {
 	return str, nil
 }
 
+func (tr *TextReporter) printVersions(w *tabwriter.Writer) {
+	in := tr.Result.Insights
+	for i := 0; i < in.NumVersions; i++ {
+		fmt.Fprintf(w, "\t %s", in.TrackVersionStr(i))
+	}
+}
+
 // printSLOsText prints all SLOs into tab writer
 func (tr *TextReporter) printSLOsText(w *tabwriter.Writer) {
 	in := tr.Result.Insights
 	fmt.Fprint(w, "SLO Conditions")
 	if in.NumVersions > 1 {
-		for i := 0; i < in.NumVersions; i++ {
-			fmt.Fprintf(w, "\t version %v", i)
-		}
+		tr.printVersions(w)
 	} else {
-		fmt.Fprintf(w, "\tSatisfied")
+		fmt.Fprintf(w, "\t Satisfied")
+	}
+	fmt.Fprintln(w)
+	fmt.Fprint(w, "--------------")
+	for i := 0; i < in.NumVersions; i++ {
+		fmt.Fprint(w, "\t ---------")
 	}
 	fmt.Fprintln(w)
-	fmt.Fprintln(w, "--------------\t---------")
 
 	if in.SLOs != nil {
 		log.Logger.Debug("SLOs are not nil")
@@ -104,9 +113,9 @@ func (tr *TextReporter) printSLOsText(w *tabwriter.Writer) {
 			if err == nil {
 				fmt.Fprint(w, str)
 				for j := 0; j < in.NumVersions; j++ {
-					fmt.Fprintf(w, "\t%v", in.SLOsSatisfied.Upper[i][j])
-					fmt.Fprintln(w)
+					fmt.Fprintf(w, "\t %v", in.SLOsSatisfied.Upper[i][j])
 				}
+				fmt.Fprintln(w)
 			} else {
 				log.Logger.Error("unable to extract SLO text")
 			}
@@ -119,9 +128,9 @@ func (tr *TextReporter) printSLOsText(w *tabwriter.Writer) {
 			if err == nil {
 				fmt.Fprint(w, str)
 				for j := 0; j < in.NumVersions; j++ {
-					fmt.Fprintf(w, "\t%v", in.SLOsSatisfied.Lower[i][j])
-					fmt.Fprintln(w)
+					fmt.Fprintf(w, "\t %v", in.SLOsSatisfied.Lower[i][j])
 				}
+				fmt.Fprintln(w)
 			} else {
 				log.Logger.Error("unable to extract SLO text")
 			}
@@ -144,14 +153,16 @@ func (tr *TextReporter) printMetricsText(w *tabwriter.Writer) {
 	in := tr.Result.Insights
 	fmt.Fprint(w, "Metric")
 	if in.NumVersions > 1 {
-		for i := 0; i < in.NumVersions; i++ {
-			fmt.Fprintf(w, "\tversion %v", i)
-		}
+		tr.printVersions(w)
 	} else {
-		fmt.Fprintf(w, "\tvalue")
+		fmt.Fprintf(w, "\t value")
+	}
+	fmt.Fprintln(w)
+	fmt.Fprint(w, "-------")
+	for i := 0; i < in.NumVersions; i++ {
+		fmt.Fprint(w, "\t -----")
 	}
 	fmt.Fprintln(w)
-	fmt.Fprintln(w, "-------\t-----")
 
 	// keys contain normalized scalar metric names in sorted order
 	keys := tr.SortedScalarAndSLOMetrics()
@@ -163,7 +174,7 @@ func (tr *TextReporter) printMetricsText(w *tabwriter.Writer) {
 			fmt.Fprint(w, mwu)
 			// add value
 			for j := 0; j < in.NumVersions; j++ {
-				fmt.Fprintf(w, "\t%v", tr.ScalarMetricValueStr(j, mn))
+				fmt.Fprintf(w, "\t %v", tr.ScalarMetricValueStr(j, mn))
 			}
 			fmt.Fprintln(w)
 		} else {
diff --git a/base/abn_metrics.go b/base/abn_metrics.go
new file mode 100644
index 00000000..6276915a
--- /dev/null
+++ b/base/abn_metrics.go
@@ -0,0 +1,166 @@
+package base
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	pb "github.com/iter8-tools/iter8/abn/grpc"
+	log "github.com/iter8-tools/iter8/base/log"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+	"k8s.io/apimachinery/pkg/util/yaml"
+)
+
+// abnClientInterface is interface for calling gRPC services
+type abnClientInterface interface {
+	callGetApplicationJSON(appName string) (string, error)
+}
+
+// defaultABNClient is default implementation of interface that calls the service
+type defaultABNClient struct {
+	endpoint string
+}
+
+func (wc *defaultABNClient) callGetApplicationJSON(appName string) (string, error) {
+	// setup client
+	opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+	conn, err := grpc.Dial(wc.endpoint, opts...)
+	if err != nil {
+		return "", err
+	}
+	c := pb.NewABNClient(conn)
+
+	// get application
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	s, err := c.GetApplicationData(
+		ctx,
+		&pb.ApplicationRequest{
+			Application: appName,
+		},
+	)
+	if err != nil {
+		return "", err
+	}
+	applicationJSON := s.GetApplicationJson()
+
+	return applicationJSON, nil
+}
+
+const (
+	// CollectABNMetrics is the name of the task to read A/B(/n) metric values
+	CollectABNMetrics = "abnmetrics"
+	// abnMetricProvider is prefix for abn metrics
+	abnMetricProvider = "abn"
+)
+
+// ABNMetricsInputs is the inputs for for the abnmetrics task
+type ABNMetricsInputs struct {
+	Endpoint *string `json:"endpoint" yaml:"endpoint"`
+	// Application is name of application to evaluate
+	Application string `json:"application" yaml:"application"`
+}
+
+// collectABNMetricsTask is task definition for abnmetrics task
+type collectABNMetricsTask struct {
+	TaskMeta
+	With   ABNMetricsInputs `json:"with" yaml:"with"`
+	client abnClientInterface
+}
+
+// initializeDefaults sets default values for the task
+func (t *collectABNMetricsTask) initializeDefaults() {
+	if t.client == nil {
+		t.client = &defaultABNClient{
+			endpoint: *t.With.Endpoint,
+		}
+	}
+}
+
+// validate task inputs
+func (t *collectABNMetricsTask) validateInputs() error {
+	return nil
+}
+
+// run exeuctes this task
+func (t *collectABNMetricsTask) run(exp *Experiment) error {
+	var err error
+
+	// validate inputs
+	err = t.validateInputs()
+	if err != nil {
+		return err
+	}
+
+	// initialize defaults
+	t.initializeDefaults()
+
+	// get application json from abn service
+	applicationJSON, err := t.client.callGetApplicationJSON(t.With.Application)
+	if err != nil {
+		return err
+	}
+
+	// convert to Application
+	a := &abnapp.Application{}
+	err = yaml.Unmarshal([]byte(applicationJSON), a)
+	if err != nil {
+		return err
+	}
+
+	// count number of tracks
+	numTracks := len(a.Tracks)
+	if numTracks == 0 {
+		log.Logger.Warn("no tracks detected in application")
+		return nil
+	}
+
+	// initialize insights in Result with number of tracks
+	err = exp.Result.initInsightsWithNumVersions(numTracks)
+	if err != nil {
+		return err
+	}
+	log.Logger.Tracef("intialized insights with %d versions", numTracks)
+
+	in := exp.Result.Insights
+
+	// add metrics for all tracks
+	versionIndex := 0
+	in.VersionNames = make([]VersionInfo, in.NumVersions)
+	// for each track (and corresponding version), get the Version object
+	// Use it to update all metrics for this version
+	for track, version := range a.Tracks {
+		// set the track identifier/version name in result
+		in.VersionNames[versionIndex].Version = version
+		in.VersionNames[versionIndex].Track = track
+		// get version object from retrieved application object
+		v, _ := a.GetVersion(version, false)
+		if v == nil {
+			err := fmt.Errorf("expected version %s not found", version)
+			log.Logger.Error(err)
+			return err
+		}
+		log.Logger.Tracef("version %s is mapped to track %s; using index %d", version, track, versionIndex)
+		// update all metrics with new values (is summary metric so just replace)
+		for metric, m := range v.Metrics {
+			log.Logger.Tracef("   updating metric %s with data %+v", metric, m)
+			err := in.updateMetric(
+				abnMetricProvider+"/"+metric,
+				MetricMeta{
+					Type: SummaryMetricType,
+				},
+				versionIndex,
+				m,
+			)
+			if err != nil {
+				return err
+			}
+		}
+		versionIndex++
+	}
+
+	return nil
+}
diff --git a/base/experiment.go b/base/experiment.go
index 59e4dcd8..31de2bf9 100644
--- a/base/experiment.go
+++ b/base/experiment.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"math"
 	"reflect"
 	"regexp"
 	"strconv"
@@ -11,6 +12,7 @@ import (
 
 	"github.com/antonmedv/expr"
 	log "github.com/iter8-tools/iter8/base/log"
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 	"github.com/montanaflynn/stats"
 	"helm.sh/helm/v3/pkg/time"
 )
@@ -76,6 +78,9 @@ type Insights struct {
 	// NumVersions is the number of app versions detected by Iter8
 	NumVersions int `json:"numVersions" yaml:"numVersions"`
 
+	// VersionNames is list of version identifiers if known
+	VersionNames []VersionInfo `json:"versionNames" yaml:"versionNames"`
+
 	// MetricsInfo identifies the metrics involved in this experiment
 	MetricsInfo map[string]MetricMeta `json:"metricsInfo,omitempty" yaml:"metricsInfo,omitempty"`
 
@@ -92,6 +97,11 @@ type Insights struct {
 	// the inner slice contains the list of all observed histogram buckets for a given version and given metric; value [i]["foo/bar"][k] is the [k]th observed bucket for version [i] for the hist metric `bar` under backend `foo`.
 	HistMetricValues []map[string][]HistBucket `json:"histMetricValues,omitempty" yaml:"histMetricValues,omitempty"`
 
+	// SummaryMetricValues:
+	// the outer slice must be the same length as the number of tracks
+	// the map key must match the name of the summary metric in MetricsInfo
+	SummaryMetricValues []map[string]summarymetrics.SummaryMetric
+
 	// SLOs involved in this experiment
 	SLOs *SLOLimits `json:"SLOs,omitempty" yaml:"SLOs,omitempty"`
 
@@ -109,6 +119,15 @@ type MetricMeta struct {
 	Type MetricType `json:"type" yaml:"type"`
 }
 
+// VersionInfo is basic information about a version
+type VersionInfo struct {
+	// Version name
+	Version string `json:"version" yaml:"version"`
+
+	// Track identifier assigned to version
+	Track string `json:"track" yaml:"track"`
+}
+
 // SLO is a service level objective
 type SLO struct {
 	// Metric is the fully qualified metric name in the backendName/metricName format
@@ -224,6 +243,14 @@ func (s *ExperimentSpec) UnmarshalJSON(data []byte) error {
 					return e
 				}
 				tsk = cgt
+			case CollectABNMetrics:
+				cgt := &collectABNMetricsTask{}
+				if err := json.Unmarshal(tBytes, cgt); err != nil {
+					e := errors.New("json unmarshal error")
+					log.Logger.WithStackTrace(err.Error()).Error(e)
+					return e
+				}
+				tsk = cgt
 			case AssessTaskName:
 				at := &assessTask{}
 				if err := json.Unmarshal(tBytes, at); err != nil {
@@ -257,20 +284,13 @@ func (s *ExperimentSpec) UnmarshalJSON(data []byte) error {
 func metricTypeMatch(t MetricType, val interface{}) bool {
 	switch v := val.(type) {
 	case float64:
-		if t == CounterMetricType || t == GaugeMetricType {
-			return true
-		}
-		return false
+		return t == CounterMetricType || t == GaugeMetricType
 	case []float64:
-		if t == SampleMetricType {
-			return true
-		}
-		return false
+		return t == SampleMetricType
 	case []HistBucket:
-		if t == HistogramMetricType {
-			return true
-		}
-		return false
+		return t == HistogramMetricType
+	case *summarymetrics.SummaryMetric:
+		return t == SummaryMetricType
 	default:
 		log.Logger.Error("unsupported type for metric value: ", v)
 		return false
@@ -292,6 +312,11 @@ func (in *Insights) updateMetricValueHist(m string, i int, val []HistBucket) {
 	in.HistMetricValues[i][m] = append(in.HistMetricValues[i][m], val...)
 }
 
+// updateSummaryMetric updates a summary metric value for a given version
+func (in *Insights) updateSummaryMetric(m string, i int, val *summarymetrics.SummaryMetric) {
+	in.SummaryMetricValues[i][m] = *val
+}
+
 // registerMetric registers a new metric by adding its meta data
 func (in *Insights) registerMetric(m string, mm MetricMeta) error {
 	if old, ok := in.MetricsInfo[m]; ok && !reflect.DeepEqual(old, mm) {
@@ -336,6 +361,8 @@ func (in *Insights) updateMetric(m string, mm MetricMeta, i int, val interface{}
 		in.updateMetricValueVector(nm, i, val.([]float64))
 	case HistogramMetricType:
 		in.updateMetricValueHist(nm, i, val.([]HistBucket))
+	case SummaryMetricType:
+		in.updateSummaryMetric(nm, i, val.(*summarymetrics.SummaryMetric))
 	default:
 		err := fmt.Errorf("unknown metric type %v", mm.Type)
 		log.Logger.Error(err)
@@ -360,6 +387,28 @@ func (in *Insights) setSLOs(slos *SLOLimits) error {
 	return nil
 }
 
+// TrackVersionStr creates a string of version name/track for display purposes
+func (in *Insights) TrackVersionStr(i int) string {
+	// if VersionNames not defined or all fields empty return default "version i"
+	if in.VersionNames == nil ||
+		len(in.VersionNames) == 0 ||
+		len(in.VersionNames[i].Version)+len(in.VersionNames[i].Track) == 0 {
+		return fmt.Sprintf("version %d", i)
+	}
+
+	if len(in.VersionNames[i].Track) == 0 {
+		// version not ""
+		return in.VersionNames[i].Version
+	}
+
+	if len(in.VersionNames[i].Version) == 0 {
+		// track not ""
+		return in.VersionNames[i].Track
+	}
+
+	return in.VersionNames[i].Track + " (" + in.VersionNames[i].Version + ")"
+}
+
 // initializeSLOsSatisfied initializes the SLOs satisfied field
 func (exp *Experiment) initializeSLOsSatisfied() error {
 	if exp.Result.Insights.SLOsSatisfied != nil {
@@ -415,13 +464,17 @@ func (r *ExperimentResult) initInsightsWithNumVersions(n int) error {
 
 // initMetrics initializes the data structes inside insights that will hold metrics
 func (in *Insights) initMetrics() error {
-	if in.NonHistMetricValues != nil || in.HistMetricValues != nil {
-		if len(in.NonHistMetricValues) != in.NumVersions || len(in.HistMetricValues) != in.NumVersions {
+	if in.NonHistMetricValues != nil ||
+		in.HistMetricValues != nil ||
+		in.SummaryMetricValues != nil {
+		if len(in.NonHistMetricValues) != in.NumVersions ||
+			len(in.HistMetricValues) != in.NumVersions ||
+			len(in.SummaryMetricValues) != in.NumVersions {
 			err := fmt.Errorf("inconsistent number for app versions in non hist metric values (%v), hist metric values (%v), num versions (%v)", len(in.NonHistMetricValues), len(in.HistMetricValues), in.NumVersions)
 			log.Logger.Error(err)
 			return err
 		}
-		if len(in.NonHistMetricValues[0])+len(in.HistMetricValues[0]) != len(in.MetricsInfo) {
+		if len(in.NonHistMetricValues[0])+len(in.HistMetricValues[0])+len(in.SummaryMetricValues[0]) != len(in.MetricsInfo) {
 			err := fmt.Errorf("inconsistent number for metrics in non hist metric values (%v), hist metric values (%v), metrics info (%v)", len(in.NonHistMetricValues[0]), len(in.HistMetricValues[0]), len(in.MetricsInfo))
 			log.Logger.Error(err)
 			return err
@@ -435,9 +488,12 @@ func (in *Insights) initMetrics() error {
 	in.NonHistMetricValues = make([]map[string][]float64, in.NumVersions)
 	// initialize hist metric values for each version
 	in.HistMetricValues = make([]map[string][]HistBucket, in.NumVersions)
+	// initialize summary metric values for each version
+	in.SummaryMetricValues = make([]map[string]summarymetrics.SummaryMetric, in.NumVersions)
 	for i := 0; i < in.NumVersions; i++ {
 		in.NonHistMetricValues[i] = make(map[string][]float64)
 		in.HistMetricValues[i] = make(map[string][]HistBucket)
+		in.SummaryMetricValues[i] = make(map[string]summarymetrics.SummaryMetric)
 	}
 	return nil
 }
@@ -475,6 +531,7 @@ func (in *Insights) getSampleAggregation(i int, baseMetric string, a string) *fl
 	vals := in.NonHistMetricValues[i][baseMetric]
 	if len(vals) == 0 {
 		log.Logger.Infof("metric %v for version %v has no sample", baseMetric, i)
+		return nil
 	}
 	if len(vals) == 1 {
 		log.Logger.Warnf("metric %v for version %v has sample of size 1", baseMetric, i)
@@ -503,7 +560,7 @@ func (in *Insights) getSampleAggregation(i int, baseMetric string, a string) *fl
 		log.Logger.WithStackTrace(err.Error()).Errorf("aggregation error version %v, metric %v, and aggregation func %v", i, baseMetric, a)
 		return nil
 	case MaxAggregator:
-		agg, err := stats.Mean(vals)
+		agg, err := stats.Max(vals)
 		if err == nil {
 			return float64Pointer(agg)
 		}
@@ -539,17 +596,56 @@ func (in *Insights) getSampleAggregation(i int, baseMetric string, a string) *fl
 	return nil
 }
 
+// getSummaryAggregation aggregates the given base metric for the given version (i) with the given aggregation (a)
+func (in *Insights) getSummaryAggregation(i int, baseMetric string, a string) *float64 {
+	at := AggregationType(a)
+	m, ok := in.SummaryMetricValues[i][baseMetric]
+	if !ok { // metric not in list
+		log.Logger.Errorf("invalid metric %s", baseMetric)
+		return nil
+	}
+
+	switch at {
+	case CountAggregator:
+		return float64Pointer(float64(m.Count()))
+	case MeanAggregator:
+		return float64Pointer(m.Sum() / float64(m.Count()))
+	case StdDevAggregator:
+		// sample variance (bessel's correction)
+		// ss / (count -1) - mean^2 * count / (count -1)
+		mean := m.Sum() / float64(m.Count())
+		nMinus1 := float64(m.Count() - 1)
+		return float64Pointer(math.Sqrt((m.SumSquares() / nMinus1) - (mean*mean*float64(m.Count()))/nMinus1))
+	case MinAggregator:
+		return float64Pointer(m.Min())
+	case MaxAggregator:
+		return float64Pointer(m.Max())
+	default:
+		// unknown, do nothing
+	}
+	log.Logger.Errorf("invalid aggregation %v", a)
+	return nil
+}
+
 // aggregateMetric returns the aggregated metric value for a given version and metric
 func (in *Insights) aggregateMetric(i int, m string) *float64 {
 	s := strings.Split(m, "/")
+	if len(s) != 3 {
+		// should not have been called
+		log.Logger.Errorf("metric name %v not valid for aggregation", m)
+		return nil
+	}
 	baseMetric := s[0] + "/" + s[1]
 	if m, ok := in.MetricsInfo[baseMetric]; ok {
 		log.Logger.Tracef("found metric %v used for aggregation", baseMetric)
 		if m.Type == SampleMetricType {
 			log.Logger.Tracef("metric %v used for aggregation is a sample metric", baseMetric)
 			return in.getSampleAggregation(i, baseMetric, s[2])
+		} else if m.Type == SummaryMetricType {
+			log.Logger.Tracef("metric %v used for aggregation is a summary metric", baseMetric)
+			return in.getSummaryAggregation(i, baseMetric, s[2])
 		}
-		log.Logger.Errorf("metric %v used for aggregation is not a sample metric", baseMetric)
+		log.Logger.Errorf("metric %v used for aggregation is not a sample or summary metric", baseMetric)
 		return nil
 	}
 	log.Logger.Warnf("could not find metric %v used for aggregation", baseMetric)
diff --git a/base/metrics.go b/base/metrics.go
index 17913dc8..136e5914 100644
--- a/base/metrics.go
+++ b/base/metrics.go
@@ -25,6 +25,8 @@ const (
 	HistogramMetricType MetricType = "Histogram"
 	// SampleMetricType corresponds to a Sample metric type
 	SampleMetricType MetricType = "Sample"
+	// SummaryMetricType corresponds to a Summary metric type
+	SummaryMetricType MetricType = "Summary"
 
 	// decimalRegex is the regex used to identify percentiles
 	decimalRegex = `^([\d]+(\.[\d]*)?|\.[\d]+)$`
diff --git a/base/summarymetrics/doc.go b/base/summarymetrics/doc.go
new file mode 100644
index 00000000..9560ba56
--- /dev/null
+++ b/base/summarymetrics/doc.go
@@ -0,0 +1,2 @@
+// Package summarymetrics provides a summary metric implementation
+package summarymetrics
diff --git a/abn/application/summary_metric.go b/base/summarymetrics/summary_metric.go
similarity index 99%
rename from abn/application/summary_metric.go
rename to base/summarymetrics/summary_metric.go
index 1e50df74..ac40af39 100644
--- a/abn/application/summary_metric.go
+++ b/base/summarymetrics/summary_metric.go
@@ -1,4 +1,4 @@
-package application
+package summarymetrics
 
 // summary_metric.go - defines a summary metric object. For space efficiency it is an array of 6 float64.
 
diff --git a/charts/iter8/templates/_experiment.tpl b/charts/iter8/templates/_experiment.tpl
index 9ba71693..db0e6a62 100644
--- a/charts/iter8/templates/_experiment.tpl
+++ b/charts/iter8/templates/_experiment.tpl
@@ -6,6 +6,8 @@ spec:
   {{- range .Values.tasks }}
   {{- if eq "assess" . }}
   {{- include "task.assess" $.Values.assess -}}
+  {{- else if eq "abnmetrics" . }}
+  {{- include "task.abnmetrics" $.Values.abnmetrics -}}
   {{- else if eq "custommetrics" . }}
   {{- include "task.custommetrics" $.Values.custommetrics -}}
   {{- else if eq "grpc" . }}
diff --git a/charts/iter8/templates/_k-role.tpl b/charts/iter8/templates/_k-role.tpl
index 4b504e93..e37308c6 100644
--- a/charts/iter8/templates/_k-role.tpl
+++ b/charts/iter8/templates/_k-role.tpl
@@ -1,6 +1,17 @@
 {{- define "k.role" -}}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
+metadata:
+  name: {{ .Release.Name }}-metricstore
+  annotations:
+    iter8.tools/group: {{ .Release.Name }}
+rules:
+- apiGroups: [""]
+  resources: ["secrets"]
+  verbs: ["list", "get"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
 metadata:
   name: {{ .Release.Name }}
   annotations:
diff --git a/charts/iter8/templates/_k-rolebinding.tpl b/charts/iter8/templates/_k-rolebinding.tpl
index 5487d050..1c2794b4 100644
--- a/charts/iter8/templates/_k-rolebinding.tpl
+++ b/charts/iter8/templates/_k-rolebinding.tpl
@@ -1,6 +1,21 @@
 {{- define "k.rolebinding" -}}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: RoleBinding
+metadata:
+  name: {{ .Release.Name }}-metricstore
+  annotations:
+    iter8.tools/group: {{ .Release.Name }}
+subjects:
+- kind: ServiceAccount
+  name: {{ .Release.Name }}-iter8-sa
+  namespace: {{ .Release.Namespace }}
+roleRef:
+  kind: Role
+  name: {{ .Release.Name }}-metricstore
+  apiGroup: rbac.authorization.k8s.io
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
 metadata:
   name: {{ .Release.Name }}
   annotations:
diff --git a/charts/iter8/templates/_task-abnmetric.tpl b/charts/iter8/templates/_task-abnmetric.tpl
new file mode 100644
index 00000000..a69801e7
--- /dev/null
+++ b/charts/iter8/templates/_task-abnmetric.tpl
@@ -0,0 +1,6 @@
+{{- define "task.abnmetrics" }}
+# task: collect metrics written by Iter8 SDK
+- task: abnmetrics
+  with:
+{{ . | toYaml | indent 4 }}
+{{- end }}
\ No newline at end of file
diff --git a/charts/iter8/values.yaml b/charts/iter8/values.yaml
index 145dd49b..ddf1059b 100644
--- a/charts/iter8/values.yaml
+++ b/charts/iter8/values.yaml
@@ -7,4 +7,7 @@ majorMinor: v0.11
 ### runner for Kubernetes experiments may be job, cronjob, or none
 runner: none
 
-logLevel: info
\ No newline at end of file
+logLevel: info
+
+abnmetrics:
+  endpoint: iter8-abn:50051
\ No newline at end of file
diff --git a/cmd/abn.go b/cmd/abn.go
index acffea33..90e867d4 100644
--- a/cmd/abn.go
+++ b/cmd/abn.go
@@ -1,8 +1,11 @@
 package cmd
 
 import (
-	abn "github.com/iter8-tools/iter8/abn/core"
+	"os"
+	"os/signal"
+	"syscall"
 
+	abn "github.com/iter8-tools/iter8/abn/core"
 	"github.com/spf13/cobra"
 )
 
@@ -13,6 +16,9 @@ Run the Iter8 A/B(/n) service.
 	iter8 abn
 `
 
+// port number on which gRPC service should listen
+var port int
+
 // newAbnCmd creates the abn command
 func newAbnCmd() *cobra.Command {
 
@@ -21,11 +27,25 @@ func newAbnCmd() *cobra.Command {
 		Short: "Start the Iter8 A/B(/n) service",
 		Long:  abnDesc,
 		RunE: func(_ *cobra.Command, _ []string) error {
-			abn.Start()
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			if err := abn.Start(port, stopCh); err != nil {
+				return err
+			}
+			sigCh := make(chan os.Signal, 1)
+			signal.Notify(sigCh, syscall.SIGTERM, os.Interrupt)
+			<-sigCh
+
 			return nil
 		},
 		SilenceUsage: true,
 		Hidden:       true,
 	}
+	addPortFlag(cmd, &port)
 	return cmd
 }
+
+// addTimeoutFlag adds timeout flag to command
+func addPortFlag(cmd *cobra.Command, portPtr *int) {
+	cmd.Flags().IntVar(portPtr, "port", 50051, "service port")
+}


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 420f1188..07c3b39f 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -19,10 +19,10 @@ jobs:
       run: |
         export COVERAGE=$(go tool cover -func coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}')
         echo "code coverage is at ${COVERAGE}"
-        if [ 1 -eq "$(echo "${COVERAGE} > 75.0" | bc)" ]; then \
-          echo "all good... coverage is above 75.0%"; 
+        if [ 1 -eq "$(echo "${COVERAGE} > 76.0" | bc)" ]; then \
+          echo "all good... coverage is above 76.0%"; 
         else \
-          echo "not good... coverage is not above 75.0%";
+          echo "not good... coverage is not above 76.0%";
           exit 1
         fi
 
diff --git a/abn/application/application_test.go b/abn/application/application_test.go
index 75364411..42a9813b 100644
--- a/abn/application/application_test.go
+++ b/abn/application/application_test.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 	"github.com/stretchr/testify/assert"
 
 	"helm.sh/helm/v3/pkg/cli"
@@ -217,11 +218,11 @@ func TestGetVersion(t *testing.T) {
 }
 
 func TestVersionAndSummaryMetric(t *testing.T) {
-	var m *SummaryMetric
+	var m *summarymetrics.SummaryMetric
 	var isNew bool
 
 	v := &Version{
-		Metrics: map[string]*SummaryMetric{},
+		Metrics: map[string]*summarymetrics.SummaryMetric{},
 	}
 
 	// test GetMetic w/o allowNew
diff --git a/abn/application/test_helper.go b/abn/application/test_helper.go
index 70821550..d05456c7 100644
--- a/abn/application/test_helper.go
+++ b/abn/application/test_helper.go
@@ -8,6 +8,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 	"github.com/stretchr/testify/assert"
 	"sigs.k8s.io/yaml"
 )
@@ -47,7 +48,7 @@ func byteArrayToApplication(name string, data []byte) (*Application, error) {
 	}
 	for _, v := range a.Versions {
 		if v.Metrics == nil {
-			v.Metrics = map[string]*SummaryMetric{}
+			v.Metrics = map[string]*summarymetrics.SummaryMetric{}
 		}
 	}
 
@@ -90,6 +91,7 @@ func assertVersion(t *testing.T, v *Version, assertion versionAssertion) bool {
 	r := true
 
 	r = r && assert.NotNil(t, v)
+	r = r && assert.Contains(t, v.String(), "- metrics:")
 
 	r = r && assert.Len(t, v.Metrics, len(assertion.metrics))
 	r = r && assert.NotNil(t, v.Metrics)
diff --git a/abn/core/grpc_service_test.go b/abn/core/grpc_service_test.go
index e06abc2d..1ce5b378 100644
--- a/abn/core/grpc_service_test.go
+++ b/abn/core/grpc_service_test.go
@@ -2,6 +2,8 @@ package core
 
 import (
 	"context"
+	"fmt"
+	"math/rand"
 	"net"
 	"os"
 	"path/filepath"
@@ -12,6 +14,7 @@ import (
 	abnapp "github.com/iter8-tools/iter8/abn/application"
 	pb "github.com/iter8-tools/iter8/abn/grpc"
 	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/iter8-tools/iter8/base/summarymetrics"
 	"github.com/stretchr/testify/assert"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/credentials/insecure"
@@ -166,8 +169,12 @@ func setup(t *testing.T) (*pb.ABNClient, func()) {
 	assert.NoError(t, err)
 	abnapp.Applications.Put(a)
 
+	// 49152-65535 are recommended ports; we use a random one for testing
+	/* #nosec */
+	port := rand.Intn(65535-49152) + 49152
+
 	// start server
-	lis, err := net.Listen("tcp", "127.0.0.1:12345")
+	lis, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
 	assert.NoError(t, err)
 
 	serverOptions := []grpc.ServerOption{}
@@ -221,14 +228,14 @@ func byteArrayToApplication(name string, data []byte) (*abnapp.Application, erro
 	}
 	for _, v := range a.Versions {
 		if v.Metrics == nil {
-			v.Metrics = map[string]*abnapp.SummaryMetric{}
+			v.Metrics = map[string]*summarymetrics.SummaryMetric{}
 		}
 	}
 
 	return a, nil
 }
 
-func getMetric(a *abnapp.Application, track, metric string) *abnapp.SummaryMetric {
+func getMetric(a *abnapp.Application, track, metric string) *summarymetrics.SummaryMetric {
 	version, ok := a.Tracks[track]
 	if !ok {
 		return nil
diff --git a/abn/core/service_test.go b/abn/core/service_test.go
new file mode 100644
index 00000000..102b1a2c
--- /dev/null
+++ b/abn/core/service_test.go
@@ -0,0 +1,68 @@
+package core
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"os"
+	"testing"
+	"time"
+
+	abnapp "github.com/iter8-tools/iter8/abn/application"
+	pb "github.com/iter8-tools/iter8/abn/grpc"
+	"github.com/iter8-tools/iter8/abn/k8sclient"
+	"github.com/stretchr/testify/assert"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials/insecure"
+	"helm.sh/helm/v3/pkg/cli"
+)
+
+func TestStart(t *testing.T) {
+	abnapp.Applications.Clear()
+	k8sclient.Client = *k8sclient.NewFakeKubeClient(cli.New())
+	// set watcherConfigEnv to test config file
+	_ = os.Setenv(watcherConfigEnv, "../../testdata/abninputs/config.yaml")
+
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+
+	// 49152-65535 are recommended ports; we use a random one for testing
+	/* #nosec */
+	port := rand.Intn(65535-49152) + 49152
+
+	err := Start(port, stopCh)
+	assert.NoError(t, err)
+
+	// initially the service might take time to come up
+	assert.Eventually(
+		t,
+		func() bool {
+			_, err = callLookup(port)
+			return assert.ErrorContains(t, err, "application not found")
+		},
+		20*time.Second,
+		time.Second,
+	)
+}
+
+func callLookup(port int) (*pb.Session, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+	conn, err := grpc.Dial(fmt.Sprintf("0.0.0.0:%d", port), opts...)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = conn.Close() }()
+	c := pb.NewABNClient(conn)
+
+	a, err := c.Lookup(
+		ctx,
+		&pb.Application{
+			Name: "default/application",
+			User: "user",
+		},
+	)
+	return a, err
+}
diff --git a/abn/k8sclient/test_helper.go b/abn/k8sclient/test_helper.go
index fec3dfd7..9358a06b 100644
--- a/abn/k8sclient/test_helper.go
+++ b/abn/k8sclient/test_helper.go
@@ -45,6 +45,7 @@ func NewFakeKubeClient(s *cli.EnvSettings, objects ...runtime.Object) *KubeClien
 		runtime.NewScheme(),
 		map[schema.GroupVersionResource]string{
 			{Group: "apps", Version: "v1", Resource: "deployments"}: "DeploymentList",
+			{Group: "", Version: "v1", Resource: "services"}:        "ServiceList",
 		},
 		objects...)
 
diff --git a/base/abn_metrics_test.go b/base/abn_metrics_test.go
new file mode 100644
index 00000000..3266a873
--- /dev/null
+++ b/base/abn_metrics_test.go
@@ -0,0 +1,57 @@
+package base
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// mockABNClient implements a mocked call to gRPC service
+type mockABNClient struct {
+	response []byte
+}
+
+func (c *mockABNClient) callGetApplicationJSON(appName string) (string, error) {
+	return string(c.response), nil
+}
+
+func TestABNMetricsTask(t *testing.T) {
+	fname := CompletePath("../testdata", "abninputs/application.yaml")
+	b, err := os.ReadFile(filepath.Clean(fname))
+	assert.NoError(t, err)
+
+	task := &collectABNMetricsTask{
+		TaskMeta: TaskMeta{
+			Task: StringPointer(CollectABNMetrics),
+		},
+		With: ABNMetricsInputs{
+			Application: "default/application",
+		},
+		client: &mockABNClient{
+			response: b,
+		},
+	}
+	exp := &Experiment{
+		Spec:   []Task{task},
+		Result: &ExperimentResult{},
+	}
+	exp.initResults(1)
+
+	err = task.run(exp)
+	assert.NoError(t, err)
+
+	assertCount(t, exp.Result.Insights, "default", float64(223))
+	assertCount(t, exp.Result.Insights, "candidate", float64(252))
+}
+
+func assertCount(t *testing.T, in *Insights, track string, count float64) bool {
+	for i, vn := range in.VersionNames {
+		if vn.Track == track {
+			c := in.getSummaryAggregation(i, "abn/sample_metric", "count")
+			return assert.Equal(t, count, *c)
+		}
+	}
+	return false
+}
diff --git a/base/experiment_test.go b/base/experiment_test.go
index 2ef1393a..a277a4c1 100644
--- a/base/experiment_test.go
+++ b/base/experiment_test.go
@@ -32,6 +32,13 @@ func TestReadExperiment(t *testing.T) {
 	err = yaml.Unmarshal(b, e)
 	assert.NoError(t, err)
 	assert.Equal(t, 4, len(e.Spec))
+
+	b, err = os.ReadFile(CompletePath("../testdata", "experiment_abn.yaml"))
+	assert.NoError(t, err)
+	e = &Experiment{}
+	err = yaml.Unmarshal(b, e)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, len(e.Spec))
 }
 
 func TestRunningTasks(t *testing.T) {
diff --git a/base/insights_test.go b/base/insights_test.go
new file mode 100644
index 00000000..063e963b
--- /dev/null
+++ b/base/insights_test.go
@@ -0,0 +1,125 @@
+package base
+
+import (
+	"testing"
+
+	"github.com/iter8-tools/iter8/base/summarymetrics"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTrackVersionStr(t *testing.T) {
+	scenarios := map[string]struct {
+		in          Insights
+		expectedStr string
+	}{
+		"VersionNames is nil":         {in: Insights{}, expectedStr: "version 0"},
+		"Version and Track empty":     {in: Insights{VersionNames: []VersionInfo{}}, expectedStr: "version 0"},
+		"Track is empty":              {in: Insights{VersionNames: []VersionInfo{{Version: "version"}}}, expectedStr: "version"},
+		"Version is empty":            {in: Insights{VersionNames: []VersionInfo{{Track: "track"}}}, expectedStr: "track"},
+		"Version and Track not empty": {in: Insights{VersionNames: []VersionInfo{{Track: "track", Version: "version"}}}, expectedStr: "track (version)"},
+	}
+
+	for l, s := range scenarios {
+		t.Run(l, func(t *testing.T) {
+			assert.Equal(t, s.expectedStr, s.in.TrackVersionStr(0))
+		})
+	}
+}
+
+func TestGetSummaryAggregation(t *testing.T) {
+	in := Insights{
+		// count, sum, min, max, sumsquares
+		SummaryMetricValues: []map[string]summarymetrics.SummaryMetric{{
+			"metric": [5]float64{float64(10), float64(110), float64(2), float64(20), float64(1540)},
+		}},
+	}
+
+	assert.Equal(t, float64(10), *in.getSummaryAggregation(0, "metric", "count"))
+	assert.Equal(t, float64(11), *in.getSummaryAggregation(0, "metric", "mean"))
+	// assert.Equal(t, float64(6.055300708194983), *in.getSummaryAggregation(0, "metric", "stddev"))
+	assert.Greater(t, float64(6.0553008), *in.getSummaryAggregation(0, "metric", "stddev"))
+	assert.Less(t, float64(6.0553007), *in.getSummaryAggregation(0, "metric", "stddev"))
+	assert.Equal(t, float64(2), *in.getSummaryAggregation(0, "metric", "min"))
+	assert.Equal(t, float64(20), *in.getSummaryAggregation(0, "metric", "max"))
+
+	assert.Nil(t, in.getSummaryAggregation(0, "metric", "invalid"))
+
+	assert.Nil(t, in.getSummaryAggregation(0, "notametric", "count"))
+}
+
+func TestGetSampleAggregation(t *testing.T) {
+	// no values
+	in := Insights{
+		NonHistMetricValues: []map[string][]float64{{
+			"metric": []float64{},
+		}},
+	}
+	assert.Nil(t, in.getSampleAggregation(0, "metric", "something"))
+
+	// single value
+	in = Insights{
+		NonHistMetricValues: []map[string][]float64{{
+			"metric": []float64{float64(2)},
+		}},
+	}
+	assert.Equal(t, float64(2), *in.getSampleAggregation(0, "metric", "anything"))
+
+	// multiple values
+	in = Insights{
+		NonHistMetricValues: []map[string][]float64{{
+			"metric": []float64{
+				float64(2), float64(4), float64(6), float64(8), float64(10),
+				float64(12), float64(14), float64(16), float64(18), float64(20),
+			},
+		}},
+	}
+	assert.Len(t, in.NonHistMetricValues, 1)
+	assert.Len(t, in.NonHistMetricValues[0], 1)
+	assert.Contains(t, in.NonHistMetricValues[0], "metric")
+	assert.Equal(t, float64(11), *in.getSampleAggregation(0, "metric", "mean"))
+	// assert.Equal(t, float64(5.744562646538029), *in.getSampleAggregation(0, "metric", "stddev"))
+	assert.Greater(t, float64(5.7445627), *in.getSampleAggregation(0, "metric", "stddev"))
+	assert.Less(t, float64(5.7445626), *in.getSampleAggregation(0, "metric", "stddev"))
+	assert.Equal(t, float64(2), *in.getSampleAggregation(0, "metric", "min"))
+	assert.Equal(t, float64(20), *in.getSampleAggregation(0, "metric", "max"))
+	// starts with p but not a percentile
+	assert.Nil(t, in.getSampleAggregation(0, "metric", "p-notpercent"))
+	// invalid percentile (101)
+	assert.Nil(t, in.getSampleAggregation(0, "metric", "p101"))
+	assert.Equal(t, float64(15), *in.getSampleAggregation(0, "metric", "p78.3"))
+	// not a valid aggregation
+	assert.Nil(t, in.getSampleAggregation(0, "metric", "invalid"))
+}
+
+func TestAggregateMetric(t *testing.T) {
+	in := Insights{
+		MetricsInfo: map[string]MetricMeta{
+			"prefix/summary": {Type: SummaryMetricType},
+			"prefix/sample":  {Type: SampleMetricType},
+			"prefix/counter": {Type: CounterMetricType},
+			"prefix/gauge":   {Type: GaugeMetricType},
+		},
+		NonHistMetricValues: []map[string][]float64{{
+			"prefix/sample": []float64{
+				float64(2), float64(4), float64(6), float64(8), float64(10),
+				float64(12), float64(14), float64(16), float64(18), float64(20),
+			},
+		}},
+		// count, sum, min, max, sumsquares
+		SummaryMetricValues: []map[string]summarymetrics.SummaryMetric{{
+			"prefix/summary": [5]float64{float64(10), float64(110), float64(2), float64(20), float64(1540)},
+		}},
+	}
+
+	// not enough parts
+	assert.Nil(t, in.aggregateMetric(0, "counter"))
+	// not enough parts
+	assert.Nil(t, in.aggregateMetric(0, "prefix/counter"))
+	// not a summary or sample metric
+	assert.Nil(t, in.aggregateMetric(0, "prefix/counter/mean"))
+	// not in MetricsInfo
+	assert.Nil(t, in.aggregateMetric(0, "prefix/invalid/mean"))
+
+	assert.Equal(t, float64(11), *in.aggregateMetric(0, "prefix/summary/mean"))
+	assert.Equal(t, float64(11), *in.aggregateMetric(0, "prefix/sample/mean"))
+}
diff --git a/base/summarymetrics/summary_metric_test.go b/base/summarymetrics/summary_metric_test.go
new file mode 100644
index 00000000..a1e143f4
--- /dev/null
+++ b/base/summarymetrics/summary_metric_test.go
@@ -0,0 +1,26 @@
+package summarymetrics
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestVersionAndSummaryMetric(t *testing.T) {
+	m := EmptySummaryMetric()
+
+	assert.NotNil(t, m)
+	// new metric is empty
+	assert.Equal(t, uint32(0), m.Count())
+	assert.Equal(t, float64(0), m.Sum())
+
+	// add values
+	m.Add(float64(27))
+	m.Add(float64(56))
+	assert.Equal(t, uint32(2), m.Count())
+	assert.Equal(t, float64(27), m.Min())
+	assert.Equal(t, float64(56), m.Max())
+	assert.Equal(t, float64(83), m.Sum())
+	assert.Equal(t, float64(3865), m.SumSquares())
+	assert.Equal(t, "[2] 27.000000, 56.000000, 83.000000, 3865.000000", m.String())
+}
diff --git a/testdata/abninputs/application.yaml b/testdata/abninputs/application.yaml
new file mode 100644
index 00000000..9acc3226
--- /dev/null
+++ b/testdata/abninputs/application.yaml
@@ -0,0 +1,21 @@
+name: default/backend
+tracks:
+  candidate: v2
+  default: v1
+versions:
+  v1:
+    metrics:
+      sample_metric:
+      - 223
+      - 10309
+      - 0
+      - 100
+      - 652049
+  v2:
+    metrics:
+      sample_metric:
+      - 252
+      - 12228
+      - 0
+      - 100
+      - 782208
diff --git a/testdata/experiment_abn.yaml b/testdata/experiment_abn.yaml
new file mode 100644
index 00000000..f0433fb8
--- /dev/null
+++ b/testdata/experiment_abn.yaml
@@ -0,0 +1,6 @@
+spec:
+  # task 1: collect abn metrics
+  - task: abnmetrics
+    with:
+        endpoint: iter8-abn:50051
+        application: default/backend
diff --git a/testdata/output/kreport.txt b/testdata/output/kreport.txt
index 872d874f..ef137893 100644
--- a/testdata/output/kreport.txt
+++ b/testdata/output/kreport.txt
@@ -10,33 +10,33 @@ Experiment summary:
 Whether or not service level objectives (SLOs) are satisfied:
 *************************************************************
 
-  SLO Conditions                  |Satisfied
-  --------------                  |---------
-  http/error-rate <= 0            |true
-  http/latency-mean (msec) <= 500 |true
-  http/latency-p50 (msec) <= 1000 |true
-  http/latency-p50 (msec) <= 1000 |true
-  http/latency-p95 (msec) <= 2500 |true
-  http/latency-p99 (msec) <= 5000 |true
+  SLO Conditions                  | Satisfied
+  --------------                  | ---------
+  http/error-rate <= 0            | true
+  http/latency-mean (msec) <= 500 | true
+  http/latency-p50 (msec) <= 1000 | true
+  http/latency-p50 (msec) <= 1000 | true
+  http/latency-p95 (msec) <= 2500 | true
+  http/latency-p99 (msec) <= 5000 | true
   
 
 Latest observed values for metrics:
 ***********************************
 
-  Metric                     |value
-  -------                    |-----
-  http/error-count           |0.00
-  http/error-rate            |0.00
-  http/latency-max (msec)    |272.84
-  http/latency-mean (msec)   |29.62
-  http/latency-min (msec)    |11.39
-  http/latency-p50 (msec)    |13.43
-  http/latency-p75 (msec)    |15.00
-  http/latency-p90 (msec)    |16.80
-  http/latency-p95 (msec)    |254.57
-  http/latency-p99 (msec)    |269.18
-  http/latency-p99.9 (msec)  |272.47
-  http/latency-stddev (msec) |62.82
-  http/request-count         |16.00
+  Metric                     | value
+  -------                    | -----
+  http/error-count           | 0.00
+  http/error-rate            | 0.00
+  http/latency-max (msec)    | 272.84
+  http/latency-mean (msec)   | 29.62
+  http/latency-min (msec)    | 11.39
+  http/latency-p50 (msec)    | 13.43
+  http/latency-p75 (msec)    | 15.00
+  http/latency-p90 (msec)    | 16.80
+  http/latency-p95 (msec)    | 254.57
+  http/latency-p99 (msec)    | 269.18
+  http/latency-p99.9 (msec)  | 272.47
+  http/latency-stddev (msec) | 62.82
+  http/request-count         | 16.00
   
 
diff --git a/testdata/output/report.txt b/testdata/output/report.txt
index 872d874f..ef137893 100644
--- a/testdata/output/report.txt
+++ b/testdata/output/report.txt
@@ -10,33 +10,33 @@ Experiment summary:
 Whether or not service level objectives (SLOs) are satisfied:
 *************************************************************
 
-  SLO Conditions                  |Satisfied
-  --------------                  |---------
-  http/error-rate <= 0            |true
-  http/latency-mean (msec) <= 500 |true
-  http/latency-p50 (msec) <= 1000 |true
-  http/latency-p50 (msec) <= 1000 |true
-  http/latency-p95 (msec) <= 2500 |true
-  http/latency-p99 (msec) <= 5000 |true
+  SLO Conditions                  | Satisfied
+  --------------                  | ---------
+  http/error-rate <= 0            | true
+  http/latency-mean (msec) <= 500 | true
+  http/latency-p50 (msec) <= 1000 | true
+  http/latency-p50 (msec) <= 1000 | true
+  http/latency-p95 (msec) <= 2500 | true
+  http/latency-p99 (msec) <= 5000 | true
   
 
 Latest observed values for metrics:
 ***********************************
 
-  Metric                     |value
-  -------                    |-----
-  http/error-count           |0.00
-  http/error-rate            |0.00
-  http/latency-max (msec)    |272.84
-  http/latency-mean (msec)   |29.62
-  http/latency-min (msec)    |11.39
-  http/latency-p50 (msec)    |13.43
-  http/latency-p75 (msec)    |15.00
-  http/latency-p90 (msec)    |16.80
-  http/latency-p95 (msec)    |254.57
-  http/latency-p99 (msec)    |269.18
-  http/latency-p99.9 (msec)  |272.47
-  http/latency-stddev (msec) |62.82
-  http/request-count         |16.00
+  Metric                     | value
+  -------                    | -----
+  http/error-count           | 0.00
+  http/error-rate            | 0.00
+  http/latency-max (msec)    | 272.84
+  http/latency-mean (msec)   | 29.62
+  http/latency-min (msec)    | 11.39
+  http/latency-p50 (msec)    | 13.43
+  http/latency-p75 (msec)    | 15.00
+  http/latency-p90 (msec)    | 16.80
+  http/latency-p95 (msec)    | 254.57
+  http/latency-p99 (msec)    | 269.18
+  http/latency-p99.9 (msec)  | 272.47
+  http/latency-stddev (msec) | 62.82
+  http/request-count         | 16.00