Repository: iter8-tools/iter8
Pull request #1070 (2021-12-12 13:14:54)

Improved status reporting


Patch:
diff --git a/Makefile b/Makefile
index 6acf3725..562a80f2 100644
--- a/Makefile
+++ b/Makefile
@@ -7,8 +7,8 @@ vet: ## Run go vet against code
 staticcheck:
 	staticcheck ./...
 
-test: fmt vet staticcheck ## Run tests.
-	go test ./... -race -coverprofile=coverage.out -covermode=atomic
+test: fmt vet ## Run tests.
+	go test ./... -race -coverprofile=coverage.out -covermode=atomic	
 
 coverage: test
 	@echo "test coverage: $(shell go tool cover -func coverage.out | grep total | awk '{print substr($$3, 1, length($$3)-1)}')"
diff --git a/base/assess.go b/base/assess.go
index c4e532c2..7412f79b 100644
--- a/base/assess.go
+++ b/base/assess.go
@@ -3,7 +3,6 @@ package base
 import (
 	"encoding/json"
 	"errors"
-	"fmt"
 
 	"github.com/go-playground/validator"
 	"github.com/iter8-tools/iter8/base/log"
@@ -55,23 +54,6 @@ func MakeAssess(t *TaskSpec) (Task, error) {
 	return bt, nil
 }
 
-// get string representation of SLOs
-func getSLOStrs(slos []SLO) []string {
-	sloStrs := []string{}
-	for _, v := range slos {
-		str := ""
-		if v.LowerLimit != nil {
-			str += fmt.Sprint(*v.LowerLimit, " <= ")
-		}
-		str += v.Metric
-		if v.UpperLimit != nil {
-			str += fmt.Sprint(" <= ", *v.UpperLimit)
-		}
-		sloStrs = append(sloStrs, str)
-	}
-	return sloStrs
-}
-
 // GetName returns the name of the assess task
 func (t *assessTask) GetName() string {
 	return AssessTaskName
@@ -95,8 +77,8 @@ func (t *assessTask) Run(exp *Experiment) error {
 	// set insight type (if needed)
 	exp.Result.Insights.setInsightType(InsightTypeSLO)
 
-	// set SLOStrs (if needed)
-	err := exp.Result.Insights.setSLOStrs(getSLOStrs(t.With.SLOs))
+	// set SLOs (if needed)
+	err := exp.Result.Insights.setSLOs(t.With.SLOs)
 	if err != nil {
 		return err
 	}
@@ -156,7 +138,7 @@ func computeSLOsSatisfiedBy(exp *Experiment) []int {
 	sats := []int{}
 	for j := 0; j < exp.Result.Insights.NumVersions; j++ {
 		sat := true
-		for i := range exp.Result.Insights.SLOStrs {
+		for i := range exp.Result.Insights.SLOs {
 			sat = sat && exp.Result.Insights.SLOsSatisfied[i][j]
 		}
 		if sat {
diff --git a/base/collect.go b/base/collect.go
index a3f10d20..f7664c94 100644
--- a/base/collect.go
+++ b/base/collect.go
@@ -312,7 +312,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// request count
 			m := iter8BuiltInPrefix + "/" + requestCountMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "number of requests",
+				Description: "number of requests sent",
 				Type:        CounterMetricType,
 			}
 			in.MetricValues[i][m] = append(in.MetricValues[i][m], float64(fm[i].DurationHistogram.Count))
@@ -327,7 +327,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// error count
 			m = iter8BuiltInPrefix + "/" + errorCountMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "number of errors",
+				Description: "number of responses that were errors",
 				Type:        CounterMetricType,
 			}
 			in.MetricValues[i][m] = append(in.MetricValues[i][m], val)
@@ -337,7 +337,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			rc := float64(fm[i].DurationHistogram.Count)
 			if rc != 0 {
 				in.MetricsInfo[m] = MetricMeta{
-					Description: "error rate",
+					Description: "fraction of responses that were errors",
 					Type:        GaugeMetricType,
 				}
 				in.MetricValues[i][m] = append(in.MetricValues[i][m], val/rc)
@@ -346,7 +346,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// mean-latency
 			m = iter8BuiltInPrefix + "/" + meanLatencyMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "mean latency",
+				Description: "mean of observed latency values",
 				Type:        GaugeMetricType,
 				Units:       stringPointer("msec"),
 			}
@@ -355,7 +355,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// stddev-latency
 			m = iter8BuiltInPrefix + "/" + stdDevMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "standard deviation of latency",
+				Description: "standard deviation of observed latency values",
 				Type:        GaugeMetricType,
 				Units:       stringPointer("msec"),
 			}
@@ -364,7 +364,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// min-latency
 			m = iter8BuiltInPrefix + "/" + minLatencyMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "minimum observed value of latency ",
+				Description: "minimum of observed latency values",
 				Type:        GaugeMetricType,
 				Units:       stringPointer("msec"),
 			}
@@ -373,7 +373,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			// max-latency
 			m = iter8BuiltInPrefix + "/" + maxLatencyMetricName
 			in.MetricsInfo[m] = MetricMeta{
-				Description: "maximum observed value of latency ",
+				Description: "maximum of observed latency values",
 				Type:        GaugeMetricType,
 				Units:       stringPointer("msec"),
 			}
@@ -383,7 +383,7 @@ func (t *collectTask) Run(exp *Experiment) error {
 			for _, p := range fm[i].DurationHistogram.Percentiles {
 				m = iter8BuiltInPrefix + "/" + fmt.Sprintf("p%0.1f", p.Percentile)
 				in.MetricsInfo[m] = MetricMeta{
-					Description: fmt.Sprintf("%0.1f percentile latency", p.Percentile),
+					Description: fmt.Sprintf("%0.1f-th percentile of observed latency values", p.Percentile),
 					Type:        GaugeMetricType,
 					Units:       stringPointer("msec"),
 				}
diff --git a/base/experiment.go b/base/experiment.go
index 9f4479ea..783ffc0c 100644
--- a/base/experiment.go
+++ b/base/experiment.go
@@ -59,8 +59,8 @@ type Insights struct {
 	// MetricsInfo identifies the metrics involved in this experiment
 	MetricsInfo map[string]MetricMeta `json:"metricsInfo,omitempty" yaml:"metricsInfo,omitempty"`
 
-	// SLOStrs represents the SLOs involved in this experiment in string form
-	SLOStrs []string `json:"SLOStrs,omitempty" yaml:"SLOStrs,omitempty"`
+	// SLOs involved in this experiment
+	SLOs []SLO `json:"SLOs,omitempty" yaml:"SLOs,omitempty"`
 
 	// MetricValues:
 	// the outer slice must be the same length as the number of app versions
@@ -69,7 +69,7 @@ type Insights struct {
 	MetricValues []map[string][]float64 `json:"metricValues,omitempty" yaml:"metricValues,omitempty"`
 
 	// SLOsSatisfied:
-	// the outer slice must be of the same length as SLOStrs
+	// the outer slice must be of the same length as SLOs
 	// the length of the inner slice must be the number of app versions
 	// the boolean value at [i][j] indicate if SLO [i] is satisfied by version [j]
 	SLOsSatisfied [][]bool `json:"SLOsSatisfied,omitempty" yaml:"SLOsSatisfied,omitempty"`
@@ -148,20 +148,21 @@ func (in *Insights) setInsightType(it InsightType) {
 	}
 }
 
-// setSLOStrs sets the SLOStrs field in insights
+// setSLOs sets the SLOs field in insights
 // if this function is called multiple times (example, due to looping), then
 // it is intended to be called with the same argument each time
-func (in *Insights) setSLOStrs(sloStrs []string) error {
-	if in.SLOStrs != nil {
-		if reflect.DeepEqual(in.SLOStrs, sloStrs) {
+func (in *Insights) setSLOs(slos []SLO) error {
+	if in.SLOs != nil {
+		if reflect.DeepEqual(in.SLOs, slos) {
 			return nil
 		} else {
-			log.Logger.WithStackTrace(fmt.Sprint("old: ", in.SLOStrs, "new: ", sloStrs)).Error("old and new value of sloStrs conflict")
-			return errors.New("old and new value of sloStrs conflict")
+			e := fmt.Errorf("old and new value of slos conflict")
+			log.Logger.WithStackTrace(fmt.Sprint("old: ", in.SLOs, "new: ", slos)).Error(e)
+			return e
 		}
 	}
 	// LHS will be nil
-	in.SLOStrs = sloStrs
+	in.SLOs = slos
 	return nil
 }
 
@@ -171,8 +172,8 @@ func (e *Experiment) initializeSLOsSatisfied() error {
 		return nil // already initialized
 	}
 	// LHS will be nil
-	e.Result.Insights.SLOsSatisfied = make([][]bool, len(e.Result.Insights.SLOStrs))
-	for i := 0; i < len(e.Result.Insights.SLOStrs); i++ {
+	e.Result.Insights.SLOsSatisfied = make([][]bool, len(e.Result.Insights.SLOs))
+	for i := 0; i < len(e.Result.Insights.SLOs); i++ {
 		e.Result.Insights.SLOsSatisfied[i] = make([]bool, e.Result.Insights.NumVersions)
 	}
 	return nil
diff --git a/basecli/experiment.go b/basecli/experiment.go
index 1a5c3d21..bbdba2f7 100644
--- a/basecli/experiment.go
+++ b/basecli/experiment.go
@@ -39,13 +39,10 @@ func Build(withResult bool, expio ExpIO) (*Experiment, error) {
 	}
 	e.InitResults()
 	if withResult {
-		log.Logger.Debug("with result is true... reading in result...")
 		e.Result, err = expio.ReadResult()
 		if err != nil {
 			return nil, err
 		}
-		resBytes, _ := yaml.Marshal(e.Result)
-		log.Logger.Debug("set result: ", string(resBytes))
 	}
 
 	err = e.buildTasks()
diff --git a/basecli/htmltemplate.go b/basecli/htmltemplate.go
index 14667e23..41e885c2 100644
--- a/basecli/htmltemplate.go
+++ b/basecli/htmltemplate.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 
 	"github.com/iter8-tools/iter8/base"
+	"github.com/iter8-tools/iter8/base/log"
 )
 
 type histBar struct {
@@ -20,8 +21,9 @@ type hist struct {
 }
 
 type histograms struct {
-	XAxisLabel string `json:"xAxisLabel" yaml:"xAxisLabel"`
-	Datum      []hist `json:"datum" yaml:"datum"`
+	XAxisLabel string  `json:"xAxisLabel" yaml:"xAxisLabel"`
+	Datum      []hist  `json:"datum" yaml:"datum"`
+	Width      float64 `json:"width" yaml:"width"`
 }
 
 func (hd *histograms) toJSON() string {
@@ -46,7 +48,7 @@ var formatHTML = `
 			<div class="container">
 
 				<h1 class="display-4">Experiment Report</h1>
-				<h3 class="display-6 text-muted">Status, Insights, and Metrics from Iter8 experiment</h3>
+				<h3 class="display-6">Insights from Iter8 Experiment</h3>
 				<hr>
 
 				{{ .HTMLStatus }}
@@ -101,6 +103,10 @@ func headSection() string {
 		<meta charset="utf-8">
 		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 
+
+		<!-- Font Awesome -->
+		<script src="https://kit.fontawesome.com/db794f5235.js" crossorigin="anonymous"></script>
+
 		<!-- Bootstrap CSS -->
 		<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
 
@@ -151,14 +157,25 @@ func (e *Experiment) HistData() []histograms {
 	gramsList := []histograms{}
 	for mname, minfo := range e.Result.Insights.MetricsInfo {
 		if minfo.Type == base.HistogramMetricType {
+			// figure out xAxisLabel
+			xAxisLabel := fmt.Sprintf("%v", mname)
+			if minfo.Units != nil {
+				xAxisLabel += " (" + *minfo.Units + ")"
+			}
+
 			grams := histograms{
-				XAxisLabel: fmt.Sprintf("%v", mname),
+				XAxisLabel: xAxisLabel,
 				Datum:      []hist{},
+				Width:      (*minfo.XMax - *minfo.XMin) / float64(*minfo.NumBuckets),
 			}
 			for i := 0; i < e.Result.Insights.NumVersions; i++ {
+				key := fmt.Sprintf("Version %v", i)
+				if e.Result.Insights.NumVersions == 1 {
+					key = "count"
+				}
 				gram := hist{
 					Values: []histBar{},
-					Key:    fmt.Sprintf("Version %v", i),
+					Key:    key,
 				}
 				if counts, ok := e.Result.Insights.MetricValues[i][mname]; ok && len(counts) > 0 {
 					for j := 0; j < len(counts); j++ {
@@ -185,13 +202,25 @@ func (e *Experiment) HTMLHistCharts() string {
 		var charts = [];
 		for (let i = 0; i < chartData.length; i++) {
 			nv.addGraph(function() {
-				charts.push(nv.models.multiBarChart().stacked(false).showControls(false));
+				charts.push(nv.models.multiBarChart());
+
 				charts[i]
+						.stacked(false)
+						.showControls(false)
 						.margin({left: 100, bottom: 100})
 						.useInteractiveGuideline(true)
-						.duration(250)
-						;
-
+						.duration(250);
+
+				var contentGenerator = charts[i].interactiveLayer.tooltip.contentGenerator();
+				var tooltip = charts[i].interactiveLayer.tooltip;
+				tooltip.headerFormatter(function (d) {
+					var lower = d;
+					var upper = d + chartData[i].width;
+					lower = lower.toFixed(2);
+					upper = upper.toFixed(2);
+					return "<p>Range: [" + lower + ", " + upper + "]";
+				});
+				
 				// chart sub-models (ie. xAxis, yAxis, etc) when accessed directly, return themselves, not the parent chart, so need to chain separately
 				charts[i].xAxis
 						.axisLabel(chartData[i].xAxisLabel)
@@ -225,32 +254,54 @@ func (e *Experiment) HTMLStatus() string {
 	}
 
 	failureStatus := "Experiment has failures."
-	textColor := "text-failure"
+	showClass := "show"
+	textColor := "text-danger"
+	thumbs := "down"
+	autoHide := "false"
+
 	if e.NoFailure() {
 		failureStatus = "Experiment has no failures."
 		textColor = "text-success"
+		showClass = ""
+		thumbs = "up"
 	}
 
 	taskStatus := fmt.Sprintf("%v out of %v tasks are complete.", len(e.Tasks), e.Result.NumCompletedTasks)
 
-	msg := fmt.Sprintln(taskStatus)
+	msg := fmt.Sprintln(completionStatus)
 	msg += fmt.Sprintln(failureStatus)
-	msg += fmt.Sprintln(completionStatus)
+	msg += fmt.Sprintln(taskStatus)
 
 	return fmt.Sprintf(`
-	<section class="mt-5">
-		<div class="row">
-			<div class="col-sm-12">
-				<div class="card">
-					<h5 class="card-header">Status</h5>
-					<div class="card-body">
-						<p class="card-text %v">%v</p>
-					</div>
-				</div>
+		<div class="toast fade %v mw-100" role="alert" aria-live="assertive" aria-atomic="true">
+			<div class="toast-header %v">
+				<strong class="mr-auto">
+					Experiment Status
+					&nbsp;&nbsp;
+					<i class="fas fa-thumbs-%v"></i>
+				</strong>
+				<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
+					<span aria-hidden="true">&times;</span>
+				</button>
 			</div>
-		</div>
-	</section>
-	`, textColor, msg)
+			<div class="toast-body %v">
+				%v
+			</div>
+	 	</div>
+	
+		<script>
+		$(document).ready(function(){
+			$(function () {
+				$('[data-toggle="tooltip"]').tooltip()
+			});	
+
+			$(".toast").toast({
+				autohide: %v,
+				delay: 10000
+			}).toast('show');
+		});	
+		</script>
+	`, showClass, textColor, thumbs, textColor, msg, autoHide)
 }
 
 // HTMLSLOSection prints the SLO section in HTML report
@@ -276,29 +327,78 @@ func (e *Experiment) printHTMLSLOVersions() string {
 		}
 	} else {
 		out += `
-		<th scope="col">Satisfied</th>
+		<th scope="col" class="text-center">Satisfied</th>
 		`
 	}
 	return out
 }
 
+func getMetricWithUnitsAndDescription(in *base.Insights, metricName string) (string, string, error) {
+	m, ok := in.MetricsInfo[metricName]
+	if !ok {
+		e := fmt.Errorf("unknown metric name %v", metricName)
+		log.Logger.Error(e)
+		return "", "", e
+	}
+	str := metricName
+	if m.Units != nil {
+		str = fmt.Sprintf("%v (%v)", str, *m.Units)
+	}
+	return str, m.Description, nil
+}
+
+func getMetricWithUnitsAndDescriptionHTML(in *base.Insights, metricName string) (string, error) {
+	str, desc, err := getMetricWithUnitsAndDescription(in, metricName)
+	// TODO: Tooltip with description
+	str = fmt.Sprintf(`<a href="javascript:void(0)" data-toggle="tooltip" data-placement="top" title="%v">%v</a>`, desc, str)
+	return str, err
+}
+
+func getSLOStrHTML(in *base.Insights, i int) (string, error) {
+	slo := in.SLOs[i]
+	// get metric with units and description
+	str, err := getMetricWithUnitsAndDescriptionHTML(in, slo.Metric)
+	if err != nil {
+		return "", err
+	}
+	// add lower limit if needed
+	if slo.LowerLimit != nil {
+		str = fmt.Sprintf("%0.2f &leq; %v", *slo.LowerLimit, str)
+	}
+	// add upper limit if needed
+	if slo.UpperLimit != nil {
+		str = fmt.Sprintf("%v &leq; %0.2f", str, *slo.UpperLimit)
+	}
+	return str, nil
+}
+
+func getSLOSatisfiedHTML(in *base.Insights, i int, j int) string {
+	if in.SLOsSatisfied[i][j] {
+		return `<i class="far fa-check-circle"></i>`
+	}
+	return `<i class="far fa-times-circle"></i>`
+}
+
 func (e *Experiment) printHTMLSLORows() string {
 	in := e.Result.Insights
 	out := ""
-	for i := 0; i < len(in.SLOStrs); i++ {
-		out += `<tr scope="row">` + "\n"
-		out += fmt.Sprintf(`
-		<td>%v</td>
-		`, in.SLOStrs[i])
-
-		for j := 0; j < in.NumVersions; j++ {
-			cellClass := "text-success"
-			if !in.SLOsSatisfied[i][j] {
-				cellClass = "text-danger"
-			}
+	for i := 0; i < len(in.SLOs); i++ {
+		str, err := getSLOStrHTML(in, i)
+		if err == nil {
+			out += `<tr scope="row">` + "\n"
 			out += fmt.Sprintf(`
-			<td class="%v">%v</td>
-			`, cellClass, in.SLOsSatisfied[i][j])
+			<td>%v</td>
+			`, str)
+
+			for j := 0; j < in.NumVersions; j++ {
+				cellClass := "text-success"
+				if !in.SLOsSatisfied[i][j] {
+					cellClass = "text-danger"
+				}
+				out += fmt.Sprintf(`
+				<td class="%v text-center">%v</td>
+				`, cellClass, getSLOSatisfiedHTML(in, i, j))
+			}
 		}
 	}
 	return out
@@ -357,8 +457,7 @@ func (e *Experiment) HTMLHistMetricsSection() string {
 		}
 		return `
 		<section class="mt-5">
-		<h3 class="display-6">Histogram Metrics</h3>
-		<h4 class="display-7 text-muted">Visualizations for Histogram-type Metrics</h4>
+		<h3 class="display-6">Metric Histograms</h3>
 		<hr>
 
 		` + strings.Join(divs, "\n") +
@@ -397,22 +496,18 @@ func (e *Experiment) printHTMLMetricRows() string {
 	out := ""
 	for i := 0; i < len(keys); i++ {
 		if e.Result.Insights.MetricsInfo[keys[i]].Type != base.HistogramMetricType {
-			u := ""
-			// add units if available
-			units := e.Result.Insights.MetricsInfo[keys[i]].Units
-			if units != nil {
-				u += " (" + *units + ")"
-			}
-
-			out += `<tr scope="row">` + "\n"
-			out += fmt.Sprintf(`
-			<td>%v</td>
-			`, keys[i]+u)
-
-			for j := 0; j < in.NumVersions; j++ {
+			str, err := getMetricWithUnitsAndDescriptionHTML(in, keys[i])
+			if err == nil {
+				out += `<tr scope="row">` + "\n"
 				out += fmt.Sprintf(`
 				<td>%v</td>
-				`, e.getMetricValue(keys[i], j))
+				`, str)
+
+				for j := 0; j < in.NumVersions; j++ {
+					out += fmt.Sprintf(`
+					<td>%v</td>
+					`, e.getMetricValue(keys[i], j))
+				}
 			}
 		}
 	}
@@ -423,8 +518,7 @@ func (e *Experiment) printHTMLMetricRows() string {
 func (e *Experiment) HTMLMetricsSection() string {
 	metricStrs := `
 	<section class="mt-5">
-			<h3 class="display-6">Metrics</h3>
-			<h4 class="display-7 text-muted">Latest observed values of metrics</h4>
+			<h3 class="display-6">Latest observed values for metrics</h3>
 			<hr>
 
 			<table class="table">
diff --git a/basecli/texttemplate.go b/basecli/texttemplate.go
index c7652757..d0ac815a 100644
--- a/basecli/texttemplate.go
+++ b/basecli/texttemplate.go
@@ -80,9 +80,9 @@ func (e *Experiment) printableSLOs() bool {
 	if e != nil {
 		if e.Result != nil {
 			if e.Result.Insights != nil {
-				if len(e.Result.Insights.SLOStrs) > 0 {
+				if len(e.Result.Insights.SLOs) > 0 {
 					if e.Result.Insights.NumVersions > 0 {
-						if len(e.Result.Insights.SLOsSatisfied) == len(e.Result.Insights.SLOStrs) {
+						if len(e.Result.Insights.SLOsSatisfied) == len(e.Result.Insights.SLOs) {
 							if e.Result.Insights.SLOsSatisfied[0] != nil {
 								if len(e.Result.Insights.SLOsSatisfied[0]) == e.Result.Insights.NumVersions {
 									return true
@@ -113,13 +113,16 @@ func (e *Experiment) printSLOs(w *tabwriter.Writer) {
 	fmt.Fprintln(w)
 	fmt.Fprintln(w, "-----------------------------\t-----")
 
-	for i := 0; i < len(in.SLOStrs); i++ {
-		fmt.Fprint(w, in.SLOStrs[i])
-		for j := 0; j < in.NumVersions; j++ {
-			fmt.Fprintf(w, "\t%v", in.SLOsSatisfied[i][j])
-			fmt.Fprintln(w)
+	for i := 0; i < len(in.SLOs); i++ {
+		str, _, err := getMetricWithUnitsAndDescription(in, in.SLOs[i].Metric)
+		if err == nil {
+			fmt.Fprint(w, str)
+			for j := 0; j < in.NumVersions; j++ {
+				fmt.Fprintf(w, "\t%v", in.SLOsSatisfied[i][j])
+				fmt.Fprintln(w)
+			}
+			fmt.Fprintln(w, "-----------------------------\t-----")
 		}
-		fmt.Fprintln(w, "-----------------------------\t-----")
 	}
 
 	w.Flush()
diff --git a/cmd/kubernetes.go b/cmd/kubernetes.go
index 07032d81..be21dff3 100644
--- a/cmd/kubernetes.go
+++ b/cmd/kubernetes.go
@@ -175,7 +175,6 @@ type PayloadValue struct {
 
 // write experiment result to secret in Kubernetes context
 func (f *KubernetesExpIO) WriteResult(r *basecli.Experiment) error {
-	log.Logger.Debug("write result called")
 	rBytes, _ := yaml.Marshal(r.Result)
 
 	resultSecretName := f.Name + "-result"
@@ -192,7 +191,6 @@ func (f *KubernetesExpIO) WriteResult(r *basecli.Experiment) error {
 		log.Logger.WithStackTrace(err.Error()).Error("unable to write experiment result")
 		return err
 	}
-	log.Logger.Debug("wrote result: ", r.Result)
 	return err
 }
 
diff --git a/go.mod b/go.mod
index 7f5a406a..9e7f601e 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/go-playground/universal-translator v0.18.0 // indirect
 	github.com/go-playground/validator v9.31.0+incompatible
 	github.com/hashicorp/go-getter v1.5.9
+	github.com/jarcoal/httpmock v1.0.8
 	github.com/kr/pretty v0.3.0 // indirect
 	github.com/leodido/go-urn v1.2.1 // indirect
 	github.com/pkg/errors v0.9.1
diff --git a/go.sum b/go.sum
index a346ae17..a1f01a41 100644
--- a/go.sum
+++ b/go.sum
@@ -574,6 +574,8 @@ github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/j-keck/arping v0.0.0-20160618110441-2cf9dc699c56/go.mod h1:ymszkNOg6tORTn+6F6j+Jc8TOr5osrynvN6ivFWZ2GA=
+github.com/jarcoal/httpmock v1.0.8 h1:8kI16SoO6LQKgPE7PvQuV+YuD/inwHd7fOOe2zMbo4k=
+github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
diff --git a/mkdocs/docs/getting-started/install.md b/mkdocs/docs/getting-started/install.md
index c1316847..7f498e23 100644
--- a/mkdocs/docs/getting-started/install.md
+++ b/mkdocs/docs/getting-started/install.md
@@ -9,6 +9,8 @@ hide:
 
 Install Iter8 using [Go 1.16+](https://golang.org/) as follows.
 ```shell
-GOBIN=/usr/local/bin/ go install github.com/iter8-tools/iter8@latest
+# GOBIN=/usr/local/bin/
+# Uncomment and set GOBIN in the above line
+# to install iter8 in any folder in your $PATH
+go install github.com/iter8-tools/iter8@latest
 ```
-`GOBIN` may be any folder in your `PATH`.
diff --git a/mkdocs/docs/getting-started/your-first-experiment.md b/mkdocs/docs/getting-started/your-first-experiment.md
index 92f8f7b7..036d601a 100644
--- a/mkdocs/docs/getting-started/your-first-experiment.md
+++ b/mkdocs/docs/getting-started/your-first-experiment.md
@@ -7,7 +7,14 @@ template: main.html
 !!! tip "Load test https://example.com"
     Use an [Iter8 experiment](concepts.md#what-is-an-iter8-experiment) to load test https://example.com and validate latency and error-related service level objectives (SLOs).
 
-## 1. [Install Iter8](install.md)
+## 1. Install Iter8
+Install Iter8 using [Go 1.16+](https://golang.org/) as follows.
+```shell
+# GOBIN=/usr/local/bin/
+# Uncomment and set GOBIN in the above line
+# to install iter8 in any folder in your $PATH
+go install github.com/iter8-tools/iter8@latest
+```
 
 ## 2. Download experiment
 Download the `load-test` experiment folder from the [Iter8 hub](../user-guide/topics/iter8hub.md) as follows.


Test patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/builddocs.yaml
similarity index 100%
rename from .github/workflows/build.yaml
rename to .github/workflows/builddocs.yaml
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index ae228a67..162d57dc 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -7,25 +7,30 @@ on:
 jobs:
   unit-test:
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        go: [ '1.16', '1.17' ]
     steps:
     - name: Install Go
       uses: actions/setup-go@v2
       with:
-        go-version: 1.17
-    - name: Set up linter
-      run: go get honnef.co/go/tools/cmd/staticcheck@latest
+        go-version: ${{ matrix.go }}
     - name: Check out code into the Go module directory
       uses: actions/checkout@v2
+    - uses: dominikh/staticcheck-action@v1.1.0
+      with:
+        install-go: false
+        cache-key: ${{ matrix.go }}
     - name: Test and compute coverage
-      run: make coverage # includes vet and lint (staticcheck)
+      run: make coverage # includes vet and lint
     - name: Enforce coverage
       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} > 17.0" | bc)" ]; then \
-          echo "all good... coverage is above 17.0%"; 
+        if [ 1 -eq "$(echo "${COVERAGE} > 29.0" | bc)" ]; then \
+          echo "all good... coverage is above 29.0%"; 
         else \
-          echo "not good... coverage is not above 17.0%";
+          echo "not good... coverage is not above 29.0%";
           exit 1
         fi
     - name: Upload coverage to Codecov
diff --git a/base/collect_test.go b/base/collect_test.go
index 70402670..b0846da7 100644
--- a/base/collect_test.go
+++ b/base/collect_test.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"testing"
 
+	"github.com/jarcoal/httpmock"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -67,3 +68,40 @@ func TestMakeCollect(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NotNil(t, task)
 }
+
+func TestRunCollect(t *testing.T) {
+	// valid collect task... should succeed
+	ct := &collectTask{
+		taskMeta: taskMeta{
+			Task: stringPointer(CollectTaskName),
+		},
+		With: collectInputs{
+			VersionInfo: []*version{{
+				Headers: map[string]string{},
+				URL:     "https://something.com",
+			}},
+		},
+	}
+
+	ts := &TaskSpec{}
+	b, err := json.Marshal(ct)
+	assert.NoError(t, err)
+	err = json.Unmarshal(b, ts)
+	assert.NoError(t, err)
+
+	httpmock.Activate()
+	defer httpmock.DeactivateAndReset()
+
+	// Exact URL match
+	httpmock.RegisterResponder("GET", "https://something.com",
+		httpmock.NewStringResponder(200, `[{"id": 1, "name": "My Great Thing"}]`))
+
+	exp := &Experiment{
+		Tasks:  []TaskSpec{*ts},
+		Result: &ExperimentResult{},
+	}
+	exp.InitResults()
+	err = ct.Run(exp)
+	assert.NoError(t, err)
+	assert.Equal(t, exp.Result.Insights.NumVersions, 1)
+}