From d7ceb03e758acd30fef66fc51a5276df4e154cc1 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 10 Oct 2026 21:11:22 +0530 Subject: [PATCH 1/4] fix(aws-cloudwatch): return DashboardNotFoundError shape for missing dashboards GetDashboard and DeleteDashboards list DashboardNotFoundError (awsQuery code ResourceNotFound, HTTP 404) in the CloudWatch model. The rpc-v2-cbor and JSON codecs wrote __type ResourceNotFound, so aws-sdk-go-v2 fell back to a generic API error instead of types.DashboardNotFoundError. Write the shape name in __type and the query code in X-Amzn-Query-Error; the query/XML code stays ResourceNotFound, which is already correct. Part of #1520 Co-Authored-By: Claude Opus 5.5 (1M context) --- server/aws/cloudwatch/dashboards.go | 4 +- server/aws/cloudwatch/dashboards_test.go | 49 +++++++++++++++++++++ server/aws/cloudwatch/handler.go | 34 ++++++++++++++ server/aws/cloudwatch/json_protocol.go | 10 ++--- server/aws/cloudwatch/json_protocol_test.go | 2 +- server/aws/cloudwatch/query_dashboards.go | 4 +- server/aws/cloudwatch/wire_error.go | 30 ++++++++++++- 7 files changed, 122 insertions(+), 11 deletions(-) diff --git a/server/aws/cloudwatch/dashboards.go b/server/aws/cloudwatch/dashboards.go index 099f9da71..1aec7b46d 100644 --- a/server/aws/cloudwatch/dashboards.go +++ b/server/aws/cloudwatch/dashboards.go @@ -101,7 +101,7 @@ func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request, body []by d, err := store.GetDashboard(r.Context(), in.DashboardName) if err != nil { - writeDriverErr(w, err) + writeDriverErr(w, dashboardErr(err)) return } @@ -196,7 +196,7 @@ func (h *Handler) deleteDashboards(w http.ResponseWriter, r *http.Request, body } if err := store.DeleteDashboards(r.Context(), in.DashboardNames); err != nil { - writeDriverErr(w, err) + writeDriverErr(w, dashboardErr(err)) return } diff --git a/server/aws/cloudwatch/dashboards_test.go b/server/aws/cloudwatch/dashboards_test.go index 8ceef660a..297c726b8 100644 --- a/server/aws/cloudwatch/dashboards_test.go +++ b/server/aws/cloudwatch/dashboards_test.go @@ -1,10 +1,14 @@ package cloudwatch_test import ( + "errors" + "net/http" "testing" "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" awscw "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" ) // TestSDKDashboardLifecycle drives the aws-sdk-go-v2 client through the @@ -98,3 +102,48 @@ func TestSDKGetDashboardNotFound(t *testing.T) { t.Fatal("GetDashboard for unknown name: expected error, got nil") } } + +// assertDashboardNotFound checks err is the model's DashboardNotFoundError: +// the SDK maps it to types.DashboardNotFoundError, its code is the awsQuery +// code ResourceNotFound and the status is 404, as in the CloudWatch API +// reference and the service model (error code ResourceNotFound, 404). +func assertDashboardNotFound(t *testing.T, op string, err error) { + t.Helper() + + var nf *cwtypes.DashboardNotFoundError + if !errors.As(err, &nf) { + t.Fatalf("%s: error %T (%v) is not *types.DashboardNotFoundError", op, err, err) + } + + if code := nf.ErrorCode(); code != "ResourceNotFound" { + t.Fatalf("%s: ErrorCode = %q, want ResourceNotFound", op, code) + } + + var re *awshttp.ResponseError + if !errors.As(err, &re) || re.HTTPStatusCode() != http.StatusNotFound { + t.Fatalf("%s: want HTTP 404, got %v", op, err) + } +} + +// TestSDKDashboardNotFoundShape covers GetDashboard and DeleteDashboards on an +// unknown dashboard. DeleteDashboards is all-or-nothing, so the existing +// dashboard named with the missing one must survive. +func TestSDKDashboardNotFoundShape(t *testing.T) { + client, ctx := newCWClient(t) + + _, err := client.GetDashboard(ctx, &awscw.GetDashboardInput{DashboardName: aws.String("missing")}) + assertDashboardNotFound(t, "GetDashboard", err) + + if _, err := client.PutDashboard(ctx, &awscw.PutDashboardInput{ + DashboardName: aws.String("keep"), DashboardBody: aws.String(`{}`), + }); err != nil { + t.Fatalf("PutDashboard: %v", err) + } + + _, err = client.DeleteDashboards(ctx, &awscw.DeleteDashboardsInput{DashboardNames: []string{"keep", "missing"}}) + assertDashboardNotFound(t, "DeleteDashboards", err) + + if _, err := client.GetDashboard(ctx, &awscw.GetDashboardInput{DashboardName: aws.String("keep")}); err != nil { + t.Fatalf("GetDashboard keep after failed delete: %v", err) + } +} diff --git a/server/aws/cloudwatch/handler.go b/server/aws/cloudwatch/handler.go index 586f96b0b..f4ced9d0f 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -255,6 +255,34 @@ func writeCBORError(w http.ResponseWriter, status int, errType, msg string) { return } + writeCBORErrorBody(w, status, errType, msg) +} + +// writeShapedError writes an error whose model shape name differs from its +// awsQuery code, such as DashboardNotFoundError (code ResourceNotFound). The +// shape goes in __type and the code in X-Amzn-Query-Error, which is how an +// awsQueryCompatible service answers in both JSON and rpc-v2-cbor. +func writeShapedError(w http.ResponseWriter, status int, shape, code, msg string) { + if jw, ok := w.(*jsonWriter); ok { + jw.writeShapedError(status, shape, code, msg) + return + } + + w.Header().Set(queryErrorHeader, code+";"+faultOf(status)) + writeCBORErrorBody(w, status, shape, msg) +} + +// faultOf is the awsQuery fault of an error status. +func faultOf(status int) string { + if status >= http.StatusInternalServerError { + return "Receiver" + } + + return "Sender" +} + +func writeCBORErrorBody(w http.ResponseWriter, status int, errType, msg string) { + payload := map[string]any{ "__type": errType, "message": msg, @@ -305,7 +333,13 @@ func writeCBORResponse(w http.ResponseWriter, payload any) { // writeDriverErr maps CloudEmu errors to CloudWatch error responses. func writeDriverErr(w http.ResponseWriter, err error) { if we, ok := asWireError(err); ok { + if we.shape != "" { + writeShapedError(w, we.status, we.shape, we.code, we.msg) + return + } + writeCBORError(w, we.status, we.code, we.msg) + return } diff --git a/server/aws/cloudwatch/json_protocol.go b/server/aws/cloudwatch/json_protocol.go index 065065372..1f69028b0 100644 --- a/server/aws/cloudwatch/json_protocol.go +++ b/server/aws/cloudwatch/json_protocol.go @@ -158,16 +158,16 @@ func (j *jsonWriter) writeError(status int, code, msg string) { shape = s } - fault := "Sender" - if status >= http.StatusInternalServerError { - fault = "Receiver" - } + j.writeShapedError(status, shape, code, msg) +} +// writeShapedError writes an awsJson1_0 error with an explicit shape name. +func (j *jsonWriter) writeShapedError(status int, shape, code, msg string) { body, _ := json.Marshal(map[string]string{"__type": shape, "message": msg}) hdr := j.w.Header() hdr.Set(errTypeHeader, shape) - hdr.Set(queryErrorHeader, code+";"+fault) + hdr.Set(queryErrorHeader, code+";"+faultOf(status)) j.WriteHeader(status) _, _ = j.Write(body) } diff --git a/server/aws/cloudwatch/json_protocol_test.go b/server/aws/cloudwatch/json_protocol_test.go index 060c6eda4..95979f3cf 100644 --- a/server/aws/cloudwatch/json_protocol_test.go +++ b/server/aws/cloudwatch/json_protocol_test.go @@ -357,7 +357,7 @@ func TestJSONProtocolErrors(t *testing.T) { }{ { name: "dashboard not found", op: "GetDashboard", body: `{"DashboardName":"nope"}`, - status: http.StatusNotFound, errType: "ResourceNotFound", queryError: "ResourceNotFound;Sender", + status: http.StatusNotFound, errType: "DashboardNotFoundError", queryError: "ResourceNotFound;Sender", }, { name: "invalid parameter value", op: "SetAlarmState", body: `{"AlarmName":"x","StateValue":"BOGUS","StateReason":"r"}`, diff --git a/server/aws/cloudwatch/query_dashboards.go b/server/aws/cloudwatch/query_dashboards.go index 43869b42f..03bb21db9 100644 --- a/server/aws/cloudwatch/query_dashboards.go +++ b/server/aws/cloudwatch/query_dashboards.go @@ -37,7 +37,7 @@ func (h *Handler) queryGetDashboard(w http.ResponseWriter, r *http.Request) { d, err := store.GetDashboard(r.Context(), r.Form.Get("DashboardName")) if err != nil { - writeQueryDriverErr(w, err) + writeQueryDriverErr(w, dashboardErr(err)) return } @@ -96,7 +96,7 @@ func (h *Handler) queryDeleteDashboards(w http.ResponseWriter, r *http.Request) } if err := store.DeleteDashboards(r.Context(), queryStringList(r, "DashboardNames.member.")); err != nil { - writeQueryDriverErr(w, err) + writeQueryDriverErr(w, dashboardErr(err)) return } diff --git a/server/aws/cloudwatch/wire_error.go b/server/aws/cloudwatch/wire_error.go index e68cece72..8daa4a287 100644 --- a/server/aws/cloudwatch/wire_error.go +++ b/server/aws/cloudwatch/wire_error.go @@ -3,6 +3,8 @@ package cloudwatch import ( "errors" "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" ) // Request error codes that CloudWatch returns with a 400. @@ -19,9 +21,13 @@ const ( const errResourceNotFoundException = "ResourceNotFoundException" // wireError is a request error that carries a real CloudWatch error code and -// HTTP status. Cores return it and both codecs write it as is. +// HTTP status. Cores return it and both codecs write it as is. shape is the +// model error shape name when it differs from the query code: the JSON and +// rpc-v2-cbor codecs put it in __type (and the code in X-Amzn-Query-Error), +// the query codec writes the code. type wireError struct { code string + shape string msg string status int } @@ -38,6 +44,28 @@ func newNotFoundError(code, msg string) error { return &wireError{code: code, msg: msg, status: http.StatusNotFound} } +// Dashboard not-found: the CloudWatch model's DashboardNotFoundError shape, +// whose awsQuery code is ResourceNotFound with HTTP 404. +const ( + errResourceNotFound = "ResourceNotFound" + shapeDashboardNotFoundErr = "DashboardNotFoundError" +) + +// dashboardErr turns a NotFound from the dashboard store into the +// DashboardNotFoundError shape. GetDashboard and DeleteDashboards list it +// instead of the generic ResourceNotFound shape, so an SDK only maps the +// error to types.DashboardNotFoundError when __type names this shape. +func dashboardErr(err error) error { + if err == nil || !cerrors.IsNotFound(err) { + return err + } + + return &wireError{ + code: errResourceNotFound, shape: shapeDashboardNotFoundErr, + msg: cerrors.Message(err), status: http.StatusNotFound, + } +} + // asWireError reports whether err is a wireError. func asWireError(err error) (*wireError, bool) { var we *wireError From 8f56d09f98981e6bdc6f2ed92e36b28b8f4cc29b Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 10 Oct 2026 21:43:02 +0530 Subject: [PATCH 2/4] feat(aws-cloudwatch): evaluate FILL, IF, SEARCH and comparison operators in metric math FILL, IF and SEARCH were stored but parsed as unsupported, so GetMetricData and metric-math alarms returned no data for them. Implement them following the CloudWatch user guide (Use metric math, search expression syntax): - comparison (== != < <= > >=) and logical (AND/&&, OR/||) operators, with a point missing from one series read as 0 - IF(cond, a[, b]) with the documented per-point rules and tables - FILL(x, S|TS|REPEAT|LINEAR) over the periods of the query range - SEARCH(' {schema} term ', 'Stat'[, period]) over metrics with data in the past two weeks; each found metric is its own GetMetricData result row Part of #1520 Co-Authored-By: Claude Opus 5.5 (1M context) --- providers/aws/cloudwatch/anomaly_detectors.go | 2 +- providers/aws/cloudwatch/math_alarms.go | 2 +- providers/aws/cloudwatch/math_alarms_test.go | 6 +- server/aws/cloudwatch/core_metric_data.go | 41 +- .../cloudwatch/metric_math_functions_test.go | 130 ++++ services/monitoring/metricmath/band.go | 2 +- services/monitoring/metricmath/eval.go | 336 ++++++++-- services/monitoring/metricmath/functions.go | 309 +++++++++ .../monitoring/metricmath/functions_test.go | 315 ++++++++++ services/monitoring/metricmath/metricmath.go | 108 +++- .../monitoring/metricmath/metricmath_test.go | 2 +- services/monitoring/metricmath/parser.go | 362 ++++++++++- services/monitoring/metricmath/search.go | 586 ++++++++++++++++++ 13 files changed, 2088 insertions(+), 113 deletions(-) create mode 100644 server/aws/cloudwatch/metric_math_functions_test.go create mode 100644 services/monitoring/metricmath/functions.go create mode 100644 services/monitoring/metricmath/functions_test.go create mode 100644 services/monitoring/metricmath/search.go diff --git a/providers/aws/cloudwatch/anomaly_detectors.go b/providers/aws/cloudwatch/anomaly_detectors.go index 28b1d8680..2cd71637f 100644 --- a/providers/aws/cloudwatch/anomaly_detectors.go +++ b/providers/aws/cloudwatch/anomaly_detectors.go @@ -243,7 +243,7 @@ func (m *Mock) detectorPoints(d *driver.AnomalyDetector, now time.Time) int { return 0 } - s, err := metricmath.New(d.Metrics, m.rangeFetcher(start, end)).Resolve(watched[0].ID) + s, err := metricmath.New(d.Metrics, m.rangeFetcher(start, end)).WithRange(start, end).Resolve(watched[0].ID) if err != nil { return 0 } diff --git a/providers/aws/cloudwatch/math_alarms.go b/providers/aws/cloudwatch/math_alarms.go index 6663f7ad7..79c7b13e3 100644 --- a/providers/aws/cloudwatch/math_alarms.go +++ b/providers/aws/cloudwatch/math_alarms.go @@ -187,7 +187,7 @@ func (m *Mock) mathDatums(a *alarmData, p *alarmeval.Params, at time.Time) []dri // Reads end before EndTime. Shifting the window by 1ns keeps a datum put // at the evaluation instant, as a plain alarm does. start, end := p.WindowStart(at).Add(time.Nanosecond), at.Add(time.Nanosecond) - ev := metricmath.New(a.Metrics, m.rangeFetcher(start, end)) + ev := metricmath.New(a.Metrics, m.rangeFetcher(start, end)).WithRange(start, end) bandID, _, isBand := bandThreshold(a) if isBand { diff --git a/providers/aws/cloudwatch/math_alarms_test.go b/providers/aws/cloudwatch/math_alarms_test.go index 9b9e9a5f4..7b478d6bc 100644 --- a/providers/aws/cloudwatch/math_alarms_test.go +++ b/providers/aws/cloudwatch/math_alarms_test.go @@ -89,11 +89,11 @@ func TestMathAlarmUnsupportedExpression(t *testing.T) { putMath(t, m, fc, "Errors", 30) - cfg := rateAlarm("fill") - cfg.Metrics[2].Expression = "FILL(err, 0)" + cfg := rateAlarm("rate-fn") + cfg.Metrics[2].Expression = "RATE(err)" requireNoError(t, m.CreateAlarm(ctx, cfg)) - assertEqual(t, stateInsufficientData, stateOf(t, m, "fill")) + assertEqual(t, stateInsufficientData, stateOf(t, m, "rate-fn")) } // The state change event lists the math alarm's queries. diff --git a/server/aws/cloudwatch/core_metric_data.go b/server/aws/cloudwatch/core_metric_data.go index a1d8ed7f7..d555f1092 100644 --- a/server/aws/cloudwatch/core_metric_data.go +++ b/server/aws/cloudwatch/core_metric_data.go @@ -89,7 +89,9 @@ type bandExclusionSource interface { } // metricDataEvaluator builds the evaluator for one GetMetricData call. A band -// trains on the two weeks before start as well. +// trains on the two weeks before start as well. FILL fills the periods of +// [start, end), and SEARCH finds the metrics ListMetrics lists: those with +// data in the past two weeks. func (h *Handler) metricDataEvaluator( ctx context.Context, queries []mondriver.MetricDataQuery, start, end time.Time, ) *metricmath.Evaluator { @@ -99,11 +101,18 @@ func (h *Handler) metricDataEvaluator( cfg.Excluded = src.BandExclusions } - return metricmath.New(queries, h.metricFetcher(ctx, start, end)).WithBand(cfg) + search := func() ([]mondriver.MetricIdentifier, error) { return h.allMetricRows(ctx, listMetricsSpan) } + + return metricmath.New(queries, h.metricFetcher(ctx, start, end)). + WithBand(cfg). + WithRange(start, end). + WithSearch(search) } // metricDataRows returns the result rows of one query. A band returns two -// rows with the query's Id, the lower edge and then the upper edge. +// rows with the query's Id, the lower edge and then the upper edge, and an +// expression that returns an array of series, such as a SEARCH, returns one +// row per series, all with the query's Id. func metricDataRows(eval *metricmath.Evaluator, q *metricDataQueryCBR, descending bool) ([]metricDataRow, error) { band, isBand, err := eval.Band(q.ID) if err != nil { @@ -117,12 +126,34 @@ func metricDataRows(eval *metricmath.Evaluator, q *metricDataQueryCBR, descendin return []metricDataRow{buildMetricDataRow(q, lower, descending), buildMetricDataRow(q, upper, descending)}, nil } - series, err := eval.Resolve(q.ID) + all, err := eval.ResolveAll(q.ID) if err != nil { return nil, err } - return []metricDataRow{buildMetricDataRow(q, series, descending)}, nil + rows := make([]metricDataRow, 0, len(all)) + + for _, s := range all { + row := buildMetricDataRow(q, s.Series, descending) + if s.Label != "" { + row.Label = seriesLabel(q.Label, s.Label) + } + + rows = append(rows, row) + } + + return rows, nil +} + +// seriesLabel is the label of one series of an array result: the query's +// Label, if any, then the series' own label, as the CloudWatch console +// captions such a series ("Expression-Label Metric-Label"). +func seriesLabel(queryLabel, ownLabel string) string { + if queryLabel == "" { + return ownLabel + } + + return queryLabel + " " + ownLabel } // metricFetcher reads one metric from the monitoring driver over [start, end). diff --git a/server/aws/cloudwatch/metric_math_functions_test.go b/server/aws/cloudwatch/metric_math_functions_test.go new file mode 100644 index 000000000..ca57fa9d4 --- /dev/null +++ b/server/aws/cloudwatch/metric_math_functions_test.go @@ -0,0 +1,130 @@ +package cloudwatch_test + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awscw "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + cwtypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" +) + +// putPoint puts one datum at ts. +func putPoint(ctx context.Context, t *testing.T, client *awscw.Client, ns, name string, dims map[string]string, + v float64, ts time.Time, +) { + t.Helper() + + d := cwtypes.MetricDatum{MetricName: aws.String(name), Value: aws.Float64(v), Timestamp: aws.Time(ts)} + for k, val := range dims { + d.Dimensions = append(d.Dimensions, cwtypes.Dimension{Name: aws.String(k), Value: aws.String(val)}) + } + + if _, err := client.PutMetricData(ctx, &awscw.PutMetricDataInput{ + Namespace: aws.String(ns), MetricData: []cwtypes.MetricDatum{d}, + }); err != nil { + t.Fatalf("PutMetricData: %v", err) + } +} + +func metricQuery(id, ns, name string) cwtypes.MetricDataQuery { + return cwtypes.MetricDataQuery{ + Id: aws.String(id), + MetricStat: &cwtypes.MetricStat{ + Metric: &cwtypes.Metric{Namespace: aws.String(ns), MetricName: aws.String(name)}, + Period: aws.Int32(60), Stat: aws.String("Sum"), + }, + ReturnData: aws.Bool(false), + } +} + +// TestSDKGetMetricDataFILLAndIF drives FILL and IF through GetMetricData. +// Over five one-minute periods m1 has data at minutes 1 and 3 only. +func TestSDKGetMetricDataFILLAndIF(t *testing.T) { + client, ctx := newCWClient(t) + + start := time.Now().UTC().Truncate(time.Minute).Add(-10 * time.Minute) + putPoint(ctx, t, client, "MathApp", "Errors", nil, 10, start.Add(1*time.Minute)) + putPoint(ctx, t, client, "MathApp", "Errors", nil, 30, start.Add(3*time.Minute)) + + out, err := client.GetMetricData(ctx, &awscw.GetMetricDataInput{ + StartTime: aws.Time(start), + EndTime: aws.Time(start.Add(5 * time.Minute)), + ScanBy: cwtypes.ScanByTimestampAscending, + MetricDataQueries: []cwtypes.MetricDataQuery{ + metricQuery("m1", "MathApp", "Errors"), + {Id: aws.String("zero"), Expression: aws.String("FILL(m1, 0)")}, + {Id: aws.String("repeat"), Expression: aws.String("FILL(m1, REPEAT)")}, + {Id: aws.String("linear"), Expression: aws.String("FILL(m1, LINEAR)")}, + {Id: aws.String("high"), Expression: aws.String("IF(m1 > 15, 1, 0)")}, + }, + }) + if err != nil { + t.Fatalf("GetMetricData: %v", err) + } + + want := map[string][]float64{ + "zero": {0, 10, 0, 30, 0}, + "repeat": {10, 10, 30, 30}, + "linear": {10, 20, 30}, + "high": {0, 1}, + } + + if len(out.MetricDataResults) != len(want) { + t.Fatalf("results = %d, want %d", len(out.MetricDataResults), len(want)) + } + + for _, r := range out.MetricDataResults { + id := aws.ToString(r.Id) + if !floatsEqual(r.Values, want[id]) { + t.Errorf("%s values = %v, want %v", id, r.Values, want[id]) + } + } +} + +// TestSDKGetMetricDataSEARCH returns one result per found metric, all with +// the query's Id, read with the search's statistic. +func TestSDKGetMetricDataSEARCH(t *testing.T) { + client, ctx := newCWClient(t) + + start := time.Now().UTC().Truncate(time.Minute).Add(-10 * time.Minute) + putPoint(ctx, t, client, "SearchApp", "Latency", map[string]string{"Host": "a"}, 5, start) + putPoint(ctx, t, client, "SearchApp", "Latency", map[string]string{"Host": "a"}, 7, start) + putPoint(ctx, t, client, "SearchApp", "Latency", map[string]string{"Host": "b"}, 9, start) + putPoint(ctx, t, client, "SearchApp", "Latency", map[string]string{"Host": "b", "Zone": "z1"}, 1, start) + putPoint(ctx, t, client, "SearchApp", "Errors", map[string]string{"Host": "a"}, 1, start) + + out, err := client.GetMetricData(ctx, &awscw.GetMetricDataInput{ + StartTime: aws.Time(start), + EndTime: aws.Time(start.Add(time.Minute)), + MetricDataQueries: []cwtypes.MetricDataQuery{{ + Id: aws.String("s1"), + Expression: aws.String(`SEARCH('{SearchApp,Host} MetricName="Latency"', 'Maximum', 60)`), + }}, + }) + if err != nil { + t.Fatalf("GetMetricData: %v", err) + } + + got := map[string][]float64{} + + for _, r := range out.MetricDataResults { + if aws.ToString(r.Id) != "s1" { + t.Fatalf("result Id = %q, want s1", aws.ToString(r.Id)) + } + + got[aws.ToString(r.Label)] = r.Values + } + + want := map[string][]float64{"a Latency": {7}, "b Latency": {9}} + if len(got) != len(want) { + t.Fatalf("results = %v, want %v", got, want) + } + + for label, v := range want { + if !floatsEqual(got[label], v) { + t.Errorf("%s = %v, want %v", label, got[label], v) + } + } +} diff --git a/services/monitoring/metricmath/band.go b/services/monitoring/metricmath/band.go index a0babb3ca..5e72ed2e5 100644 --- a/services/monitoring/metricmath/band.go +++ b/services/monitoring/metricmath/band.go @@ -122,7 +122,7 @@ func (e *Evaluator) BandAt(id string, period int) (Band, bool, error) { excluded = e.band.Excluded(e.queries, n.input) } - return computeBand(target, history, n.k, excludedPeriods{ranges: excluded, period: pointPeriod}), true, nil + return computeBand(target.asSeries(), history.asSeries(), n.k, excludedPeriods{ranges: excluded, period: pointPeriod}), true, nil } // historyEvaluator resolves the same queries over the training range. diff --git a/services/monitoring/metricmath/eval.go b/services/monitoring/metricmath/eval.go index eb7b47cef..363aafd48 100644 --- a/services/monitoring/metricmath/eval.go +++ b/services/monitoring/metricmath/eval.go @@ -1,65 +1,130 @@ package metricmath -import "time" +import ( + "sort" + "time" +) + +// Labeled is one series of an expression result together with the label of +// the metric it came from. A SEARCH returns one per metric it finds. Label is +// empty for a series that does not come from a single found metric. +type Labeled struct { + Label string + Series +} -// result is either a scalar constant or a resolved time series. +// result is a scalar constant, a single time series or an array of time +// series (TS[]). type result struct { isScalar bool scalar float64 series Series + isArray bool + array []Labeled +} + +func emptySeries() Series { + return Series{Timestamps: []time.Time{}, Values: []float64{}} } // asSeries renders a result as a series. A scalar becomes a single point, so -// a constant expression still returns a value row. +// a constant expression still returns a value row. An array has a single +// series value only when it holds exactly one series. func (r result) asSeries() Series { - if !r.isScalar { + switch { + case r.isScalar: + return Series{Timestamps: []time.Time{{}}, Values: []float64{r.scalar}} + case r.isArray: + if len(r.array) == 1 { + return r.array[0].Series + } + + return emptySeries() + default: return r.series } - - return Series{Timestamps: []time.Time{{}}, Values: []float64{r.scalar}} } -// combine applies a binary operator point by point. A scalar operand is used -// against every point of the other operand. -func combine(op byte, left, right result) result { - if left.isScalar && right.isScalar { - return result{isScalar: true, scalar: applyOp(op, left.scalar, right.scalar)} +// labeled renders a result as the list of series it returns. +func (r result) labeled() []Labeled { + if r.isArray { + return r.array } - if left.isScalar { - return scalarSeries(op, left.scalar, right.series, true) - } + return []Labeled{{Series: r.asSeries()}} +} + +// mapArray applies f to each series of an array, keeping the labels. +func mapArray(arr []Labeled, f func(result) result) result { + out := make([]Labeled, 0, len(arr)) - if right.isScalar { - return scalarSeries(op, right.scalar, left.series, false) + for _, l := range arr { + out = append(out, Labeled{Label: l.Label, Series: f(result{series: l.Series}).asSeries()}) } - return seriesSeries(op, left.series, right.series) + return result{isArray: true, array: out} } -func scalarSeries(op byte, scalar float64, s Series, scalarLeft bool) result { +// pointFunc combines two values into one. +type pointFunc func(a, b float64) float64 + +// pointwise applies f to two operands. A scalar operand is used against every +// point of the other one, and an array operand is combined series by series. +// With union set, a timestamp only one series has counts as 0 in the other, +// as metric-math comparison and logical operators do. Without it the point is +// dropped. +func pointwise(f pointFunc, left, right result, union bool) result { + switch { + case left.isArray && right.isArray: + // TS[] op TS[] is not a documented form. + return result{series: emptySeries()} + case left.isArray: + return mapArray(left.array, func(l result) result { return pointwise(f, l, right, union) }) + case right.isArray: + return mapArray(right.array, func(r result) result { return pointwise(f, left, r, union) }) + case left.isScalar && right.isScalar: + return result{isScalar: true, scalar: f(left.scalar, right.scalar)} + case left.isScalar: + return result{series: scalarSeries(f, left.scalar, right.series, true)} + case right.isScalar: + return result{series: scalarSeries(f, right.scalar, left.series, false)} + case union: + return result{series: unionSeries(f, left.series, right.series)} + default: + return result{series: intersectSeries(f, left.series, right.series)} + } +} + +func scalarSeries(f pointFunc, scalar float64, s Series, scalarLeft bool) Series { values := make([]float64, len(s.Values)) for i, v := range s.Values { if scalarLeft { - values[i] = applyOp(op, scalar, v) + values[i] = f(scalar, v) } else { - values[i] = applyOp(op, v, scalar) + values[i] = f(v, scalar) } } - return result{series: Series{Timestamps: s.Timestamps, Values: values}} + return Series{Timestamps: s.Timestamps, Values: values} } -// seriesSeries combines the points two series share a timestamp at. A point -// with no partner is dropped, so a gap in one input never shifts the other. -func seriesSeries(op byte, left, right Series) result { - rightAt := make(map[int64]float64, len(right.Timestamps)) - for i, ts := range right.Timestamps { - rightAt[ts.UnixNano()] = right.Values[i] +// valuesAt indexes a series by timestamp. +func valuesAt(s Series) map[int64]float64 { + at := make(map[int64]float64, len(s.Timestamps)) + for i, ts := range s.Timestamps { + at[ts.UnixNano()] = s.Values[i] } - out := Series{Timestamps: []time.Time{}, Values: []float64{}} + return at +} + +// intersectSeries combines the points two series share a timestamp at. A +// point with no partner is dropped, so a gap in one input never shifts the +// other. +func intersectSeries(f pointFunc, left, right Series) Series { + rightAt := valuesAt(right) + out := emptySeries() for i, ts := range left.Timestamps { rv, ok := rightAt[ts.UnixNano()] @@ -68,10 +133,50 @@ func seriesSeries(op byte, left, right Series) result { } out.Timestamps = append(out.Timestamps, ts) - out.Values = append(out.Values, applyOp(op, left.Values[i], rv)) + out.Values = append(out.Values, f(left.Values[i], rv)) + } + + return out +} + +// unionSeries combines two series at every timestamp either has, reading a +// missing point as 0, in time order. +func unionSeries(f pointFunc, left, right Series) Series { + leftAt, rightAt := valuesAt(left), valuesAt(right) + stamps := mergeTimestamps(left.Timestamps, right.Timestamps) + out := Series{Timestamps: make([]time.Time, 0, len(stamps)), Values: make([]float64, 0, len(stamps))} + + for _, ts := range stamps { + out.Timestamps = append(out.Timestamps, ts) + out.Values = append(out.Values, f(leftAt[ts.UnixNano()], rightAt[ts.UnixNano()])) } - return result{series: out} + return out +} + +// mergeTimestamps returns the distinct timestamps of all lists, sorted. +func mergeTimestamps(lists ...[]time.Time) []time.Time { + seen := map[int64]bool{} + + var out []time.Time + + for _, l := range lists { + for _, ts := range l { + if !seen[ts.UnixNano()] { + seen[ts.UnixNano()] = true + out = append(out, ts) + } + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].Before(out[j]) }) + + return out +} + +// arithmetic returns the point function of an arithmetic operator. +func arithmetic(op byte) pointFunc { + return func(a, b float64) float64 { return applyOp(op, a, b) } } func applyOp(op byte, a, b float64) float64 { @@ -93,11 +198,44 @@ func applyOp(op byte, a, b float64) float64 { } } -// scope is what a node evaluates against: the evaluator that resolves IDs -// and the period to read them at. +// truth renders a boolean as the 1 or 0 of a metric-math comparison. +func truth(b bool) float64 { + if b { + return 1 + } + + return 0 +} + +// comparison returns the point function of a comparison operator. +func comparison(op string) pointFunc { + return func(a, b float64) float64 { + switch op { + case "==": + return truth(a == b) + case "!=": + return truth(a != b) + case "<": + return truth(a < b) + case "<=": + return truth(a <= b) + case ">": + return truth(a > b) + case ">=": + return truth(a >= b) + default: + return 0 + } + } +} + +// scope is what a node evaluates against: the evaluator that resolves IDs, +// the period to read them at, and the period of the expression's own points, +// which FILL fills and SEARCH reads at. type scope struct { - e *Evaluator - period int + e *Evaluator + period int + gridPeriod int } // node is a parsed expression node. @@ -114,12 +252,22 @@ func (n numberNode) evaluate(scope) (result, error) { type refNode struct{ id string } func (n refNode) evaluate(s scope) (result, error) { - series, err := s.e.resolve(n.id, s.period) - if err != nil { - return result{}, err - } + return s.e.resolve(n.id, s.period) +} + +// stringNode is a string literal. Only SEARCH reads one, so on its own it +// has no value. +type stringNode struct{ val string } + +func (stringNode) evaluate(scope) (result, error) { + return result{series: emptySeries()}, nil +} + +// keywordNode is a bare uppercase word such as REPEAT. Only FILL reads one. +type keywordNode struct{ word string } - return result{series: series}, nil +func (keywordNode) evaluate(scope) (result, error) { + return result{series: emptySeries()}, nil } type negNode struct{ operand node } @@ -130,7 +278,7 @@ func (n negNode) evaluate(s scope) (result, error) { return result{}, err } - return combine('-', result{isScalar: true}, v), nil + return pointwise(arithmetic('-'), result{isScalar: true}, v, false), nil } type binaryNode struct { @@ -139,17 +287,64 @@ type binaryNode struct { } func (n binaryNode) evaluate(s scope) (result, error) { - left, err := n.left.evaluate(s) + left, right, err := evalPair(s, n.left, n.right) if err != nil { return result{}, err } - right, err := n.right.evaluate(s) + return pointwise(arithmetic(n.op), left, right, false), nil +} + +// compareNode is a comparison. Each point is 1 when it holds and 0 when not. +type compareNode struct { + op string + left, right node +} + +func (n compareNode) evaluate(s scope) (result, error) { + left, right, err := evalPair(s, n.left, n.right) + if err != nil { + return result{}, err + } + + return pointwise(comparison(n.op), left, right, true), nil +} + +// logicNode is AND or OR. A non-zero value is true. +type logicNode struct { + and bool + left, right node +} + +func (n logicNode) evaluate(s scope) (result, error) { + left, right, err := evalPair(s, n.left, n.right) if err != nil { return result{}, err } - return combine(n.op, left, right), nil + f := func(a, b float64) float64 { + if n.and { + return truth(a != 0 && b != 0) + } + + return truth(a != 0 || b != 0) + } + + return pointwise(f, left, right, true), nil +} + +func evalPair(s scope, a, b node) (left, right result, err error) { + left, err = a.evaluate(s) + if err != nil { + return result{}, result{}, err + } + + right, err = b.evaluate(s) + if err != nil { + return result{}, result{}, err + } + + return left, right, nil } // bandNode is ANOMALY_DETECTION_BAND(input, k). The band is two series, so @@ -161,7 +356,31 @@ type bandNode struct { } func (bandNode) evaluate(scope) (result, error) { - return result{series: Series{Timestamps: []time.Time{}, Values: []float64{}}}, nil + return result{series: emptySeries()}, nil +} + +// children returns the sub-expressions of a node. +func children(n node) []node { + switch v := n.(type) { + case negNode: + return []node{v.operand} + case binaryNode: + return []node{v.left, v.right} + case compareNode: + return []node{v.left, v.right} + case logicNode: + return []node{v.left, v.right} + case ifNode: + if v.otherwise != nil { + return []node{v.cond, v.then, v.otherwise} + } + + return []node{v.cond, v.then} + case fillNode: + return []node{v.input, v.with} + default: + return nil + } } // collectRefs appends every ID the node reads to ids. @@ -171,10 +390,29 @@ func collectRefs(n node, ids *[]string) { *ids = append(*ids, v.id) case bandNode: *ids = append(*ids, v.input) - case negNode: - collectRefs(v.operand, ids) - case binaryNode: - collectRefs(v.left, ids) - collectRefs(v.right, ids) + default: + for _, c := range children(n) { + collectRefs(c, ids) + } + } +} + +// keywordsPlaced reports whether every keyword is the filler of a FILL. +func keywordsPlaced(n node) bool { + switch v := n.(type) { + case keywordNode: + return false + case fillNode: + if _, ok := v.with.(keywordNode); ok { + return keywordsPlaced(v.input) + } + } + + for _, c := range children(n) { + if !keywordsPlaced(c) { + return false + } } + + return true } diff --git a/services/monitoring/metricmath/functions.go b/services/monitoring/metricmath/functions.go new file mode 100644 index 000000000..f98a7ee3a --- /dev/null +++ b/services/monitoring/metricmath/functions.go @@ -0,0 +1,309 @@ +package metricmath + +import "time" + +// This file holds IF and FILL. Their rules follow the CloudWatch user guide, +// "Using IF expressions" and the FILL row of the function table: +// https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html + +// ifNode is IF(condition, trueValue[, falseValue]). +type ifNode struct { + cond, then, otherwise node +} + +func (n ifNode) evaluate(s scope) (result, error) { + cond, err := n.cond.evaluate(s) + if err != nil { + return result{}, err + } + + then, err := n.then.evaluate(s) + if err != nil { + return result{}, err + } + + var otherwise *result + + if n.otherwise != nil { + v, err := n.otherwise.evaluate(s) + if err != nil { + return result{}, err + } + + otherwise = &v + } + + // IF returns a single time series. An array in any argument is not one + // of the documented forms. + if cond.isArray || then.isArray || (otherwise != nil && otherwise.isArray) { + return result{series: emptySeries()}, nil + } + + if cond.isScalar { + return ifScalar(cond.scalar, then, otherwise), nil + } + + return result{series: ifSeries(cond.series, then, otherwise)}, nil +} + +// ifScalar is IF(S, a, b): a when S is true, b when false, and an empty +// series when false and b is left out. +func ifScalar(cond float64, then result, otherwise *result) result { + if cond != 0 { + return then + } + + if otherwise == nil { + return result{series: emptySeries()} + } + + return *otherwise +} + +// ifSeries is IF(TS, a, b), point by point over the condition series: +// +// - A missing condition point gives no output point. +// - A true point takes a. A false point takes b, and gives no point when b +// is left out. +// - A scalar branch gives its value. +// - A series branch with no point at that time gives 0, except that a +// missing false-branch point gives no point when the true branch is a +// scalar. This is the IF(metric1, scalar2, metric3) table of the guide. +func ifSeries(cond Series, then result, otherwise *result) Series { + thenAt := branchValues(then) + + var otherAt map[int64]float64 + if otherwise != nil { + otherAt = branchValues(*otherwise) + } + + out := emptySeries() + + for i, ts := range cond.Timestamps { + var ( + v float64 + ok bool + ) + + if cond.Values[i] != 0 { + v, ok = branchValue(then, thenAt, ts, true) + } else if otherwise != nil { + v, ok = branchValue(*otherwise, otherAt, ts, !then.isScalar) + } + + if ok { + out.Timestamps = append(out.Timestamps, ts) + out.Values = append(out.Values, v) + } + } + + return out +} + +func branchValues(r result) map[int64]float64 { + if r.isScalar { + return nil + } + + return valuesAt(r.series) +} + +// branchValue reads one IF branch at ts. zeroIfMissing says whether a series +// branch with no point there gives 0 or no point. +func branchValue(r result, at map[int64]float64, ts time.Time, zeroIfMissing bool) (float64, bool) { + if r.isScalar { + return r.scalar, true + } + + if v, ok := at[ts.UnixNano()]; ok { + return v, true + } + + return 0, zeroIfMissing +} + +// maxFillPoints caps the points FILL creates for one series. It is the +// GetMetricData datapoint budget of one call. +const maxFillPoints = 100800 + +// fillNode is FILL(input, filler). The filler is a scalar, a series, REPEAT +// or LINEAR. +type fillNode struct { + input, with node +} + +func (n fillNode) evaluate(s scope) (result, error) { + in, err := n.input.evaluate(s) + if err != nil { + return result{}, err + } + + if in.isScalar { + return in, nil + } + + f, err := n.filler(s) + if err != nil { + return result{}, err + } + + grid := s.e.grid(s.gridPeriod) + + if in.isArray { + return mapArray(in.array, func(r result) result { return result{series: f.apply(r.series, grid)} }), nil + } + + return result{series: f.apply(in.series, grid)}, nil +} + +func (n fillNode) filler(s scope) (filler, error) { + if kw, ok := n.with.(keywordNode); ok { + return filler{mode: kw.word}, nil + } + + v, err := n.with.evaluate(s) + if err != nil { + return filler{}, err + } + + switch { + case v.isScalar: + return filler{value: &v.scalar}, nil + case v.isArray: + // The filler of FILL is a scalar, one series or a keyword. + return filler{none: true}, nil + default: + return filler{series: valuesAt(v.series)}, nil + } +} + +// filler is the value source of one FILL. +type filler struct { + mode string // kwRepeat or kwLinear, else empty + value *float64 + series map[int64]float64 + none bool +} + +// grid returns the timestamps of every period in the evaluator's range, +// aligned to its start as the metric reads are. It is nil when the range or +// the period is unknown, or the range holds more than maxFillPoints periods. +func (e *Evaluator) grid(period int) []time.Time { + if period <= 0 || e.start.IsZero() || !e.end.After(e.start) { + return nil + } + + step := time.Duration(period) * time.Second + if e.end.Sub(e.start)/step > maxFillPoints { + return nil + } + + var out []time.Time + + for ts := e.start; ts.Before(e.end); ts = ts.Add(step) { + out = append(out, ts) + } + + return out +} + +// apply fills the points of grid that s has no value at. The points of s +// are kept as they are. +func (f filler) apply(s Series, grid []time.Time) Series { + if f.none || len(grid) == 0 { + return s + } + + known := valuesAt(s) + stamps := mergeTimestamps(s.Timestamps, grid) + + switch f.mode { + case kwRepeat: + return fillRepeat(known, stamps) + case kwLinear: + return fillLinear(known, stamps) + } + + out := emptySeries() + + for _, ts := range stamps { + v, ok := known[ts.UnixNano()] + if !ok { + v, ok = f.fixed(ts) + } + + if ok { + out.Timestamps = append(out.Timestamps, ts) + out.Values = append(out.Values, v) + } + } + + return out +} + +// fixed is the scalar or series filler value at ts. +func (f filler) fixed(ts time.Time) (float64, bool) { + if f.value != nil { + return *f.value, true + } + + v, ok := f.series[ts.UnixNano()] + + return v, ok +} + +// fillRepeat fills a missing point with the latest actual value before it. +// A gap before the first actual value has nothing to repeat and stays empty. +func fillRepeat(known map[int64]float64, stamps []time.Time) Series { + out := emptySeries() + + var ( + last float64 + hasLast bool + ) + + for _, ts := range stamps { + if v, ok := known[ts.UnixNano()]; ok { + last, hasLast = v, true + } + + if hasLast { + out.Timestamps = append(out.Timestamps, ts) + out.Values = append(out.Values, last) + } + } + + return out +} + +// fillLinear fills a missing point on the straight line between the actual +// values at the start and the end of its gap. A gap before the first or +// after the last actual value has no end to interpolate to and stays empty. +func fillLinear(known map[int64]float64, stamps []time.Time) Series { + out := emptySeries() + prev := -1 + + for i, ts := range stamps { + v, ok := known[ts.UnixNano()] + if !ok { + continue + } + + if prev >= 0 { + p := stamps[prev] + pv := known[p.UnixNano()] + span := ts.Sub(p).Seconds() + + for _, gap := range stamps[prev+1 : i] { + out.Timestamps = append(out.Timestamps, gap) + out.Values = append(out.Values, pv+(v-pv)*gap.Sub(p).Seconds()/span) + } + } + + out.Timestamps = append(out.Timestamps, ts) + out.Values = append(out.Values, v) + prev = i + } + + return out +} diff --git a/services/monitoring/metricmath/functions_test.go b/services/monitoring/metricmath/functions_test.go new file mode 100644 index 000000000..1fdf5ace7 --- /dev/null +++ b/services/monitoring/metricmath/functions_test.go @@ -0,0 +1,315 @@ +package metricmath_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + "github.com/stackshy/cloudemu/v2/services/monitoring/metricmath" +) + +// resolveExpr evaluates expr as entry e1 over m1, m2 and m3 (metrics A, B +// and C at one minute) for the five minutes from base. +func resolveExpr(t *testing.T, f *fixedFetcher, expr string) metricmath.Series { + t.Helper() + + queries := []driver.MetricDataQuery{ + {ID: "m1", MetricStat: stat("A", 60), ReturnData: boolPtr(false)}, + {ID: "m2", MetricStat: stat("B", 60), ReturnData: boolPtr(false)}, + {ID: "m3", MetricStat: stat("C", 60), ReturnData: boolPtr(false)}, + {ID: "e1", Expression: expr}, + } + + s, err := metricmath.New(queries, f.fetch).WithRange(base, base.Add(5*time.Minute)).Resolve("e1") + require.NoError(t, err) + + return s +} + +// TestIFExpressions checks the three IF tables of the CloudWatch user guide +// ("Using IF expressions"). metric1 is [1, 1, 0, 0, -], metric2 is +// [30, -, 0, 0, 30] and metric3 is [0, 0, 20, -, 20]. +func TestIFExpressions(t *testing.T) { + f := &fixedFetcher{series: map[string]metricmath.Series{ + "A": {Timestamps: at(0, 1, 2, 3), Values: []float64{1, 1, 0, 0}}, + "B": {Timestamps: at(0, 2, 3, 4), Values: []float64{30, 0, 0, 30}}, + "C": {Timestamps: at(0, 1, 2, 4), Values: []float64{0, 0, 20, 20}}, + }} + + tests := []struct { + name string + expr string + want []float64 + stamp []time.Time + }{ + {"IF(metric1, metric2, metric3)", "IF(m1, m2, m3)", []float64{30, 0, 20, 0}, at(0, 1, 2, 3)}, + {"IF(metric1, scalar2, metric3)", "IF(m1, 5, m3)", []float64{5, 5, 20}, at(0, 1, 2)}, + {"IF(metric1, metric2, scalar3)", "IF(m1, m2, 5)", []float64{30, 0, 5, 5}, at(0, 1, 2, 3)}, + {"false point dropped without falseValue", "IF(m1, m2)", []float64{30, 0}, at(0, 1)}, + {"comparison condition", "IF(m3 > 10, 1, 0)", []float64{0, 0, 1, 1}, at(0, 1, 2, 4)}, + {"scalar condition true", "IF(1, m2, m3)", []float64{30, 0, 0, 30}, at(0, 2, 3, 4)}, + {"scalar condition false", "IF(0, m2, m3)", []float64{0, 0, 20, 20}, at(0, 1, 2, 4)}, + {"scalar condition false without falseValue", "IF(0, m2)", []float64{}, []time.Time{}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := resolveExpr(t, f, tc.expr) + assert.Equal(t, tc.want, s.Values) + assert.Equal(t, tc.stamp, s.Timestamps) + }) + } +} + +// TestComparisonAndLogicalOperators checks the operator table of the user +// guide: metric1 is [30, 20, 0, 0] and metric2 is [20, -, 20, -], and a point +// only one series has reads as 0 in the other. +func TestComparisonAndLogicalOperators(t *testing.T) { + f := &fixedFetcher{series: map[string]metricmath.Series{ + "A": {Timestamps: at(0, 1, 2, 3), Values: []float64{30, 20, 0, 0}}, + "B": {Timestamps: at(0, 2), Values: []float64{20, 20}}, + }} + + tests := []struct { + expr string + want []float64 + }{ + {"m1 < m2", []float64{0, 0, 1, 0}}, + {"m1 >= 30", []float64{1, 0, 0, 0}}, + {"m1 > 15 AND m2 > 15", []float64{1, 0, 0, 0}}, + {"m1 > 15 && m2 > 15", []float64{1, 0, 0, 0}}, + {"m1 > 25 OR m2 > 15", []float64{1, 0, 1, 0}}, + {"m1 > 25 || m2 > 15", []float64{1, 0, 1, 0}}, + {"m1 == 0", []float64{0, 0, 1, 1}}, + {"m1 != 0", []float64{1, 1, 0, 0}}, + {"m1 <= 20", []float64{0, 1, 1, 1}}, + } + + for _, tc := range tests { + t.Run(tc.expr, func(t *testing.T) { + s := resolveExpr(t, f, tc.expr) + assert.Equal(t, tc.want, s.Values) + }) + } +} + +// TestFILL checks each FILL filler over a five-minute range with m1 at +// minutes 1 and 3: [-, 10, -, 30, -]. +func TestFILL(t *testing.T) { + f := &fixedFetcher{series: map[string]metricmath.Series{ + "A": {Timestamps: at(1, 3), Values: []float64{10, 30}}, + "B": {Timestamps: at(0, 2), Values: []float64{7, 8}}, + }} + + tests := []struct { + name string + expr string + want []float64 + stamp []time.Time + }{ + {"scalar", "FILL(m1, 0)", []float64{0, 10, 0, 30, 0}, at(0, 1, 2, 3, 4)}, + {"scalar expression", "FILL(m1, 2*3)", []float64{6, 10, 6, 30, 6}, at(0, 1, 2, 3, 4)}, + {"metric", "FILL(m1, m2)", []float64{7, 10, 8, 30}, at(0, 1, 2, 3)}, + {"REPEAT", "FILL(m1, REPEAT)", []float64{10, 10, 30, 30}, at(1, 2, 3, 4)}, + {"LINEAR", "FILL(m1, LINEAR)", []float64{10, 20, 30}, at(1, 2, 3)}, + {"inside arithmetic", "FILL(m1, 0) + 1", []float64{1, 11, 1, 31, 1}, at(0, 1, 2, 3, 4)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := resolveExpr(t, f, tc.expr) + assert.Equal(t, tc.want, s.Values) + assert.Equal(t, tc.stamp, s.Timestamps) + }) + } +} + +// TestFILLNoData fills every period of the range when the metric has none. +func TestFILLNoData(t *testing.T) { + s := resolveExpr(t, &fixedFetcher{}, "FILL(m1, 5)") + assert.Equal(t, []float64{5, 5, 5, 5, 5}, s.Values) +} + +// TestFILLWithoutRange leaves the input as is when the range is unknown. +func TestFILLWithoutRange(t *testing.T) { + f := &fixedFetcher{series: map[string]metricmath.Series{"A": {Timestamps: at(1), Values: []float64{10}}}} + queries := []driver.MetricDataQuery{ + {ID: "m1", MetricStat: stat("A", 60), ReturnData: boolPtr(false)}, + {ID: "e1", Expression: "FILL(m1, 0)"}, + } + + s, err := metricmath.New(queries, f.fetch).Resolve("e1") + require.NoError(t, err) + assert.Equal(t, []float64{10}, s.Values) +} + +func TestFunctionReferences(t *testing.T) { + tests := []struct { + expr string + want []string + ok bool + }{ + {"IF(m1 > 5, m2, m3)", []string{"m1", "m2", "m3"}, true}, + {"FILL(m1, REPEAT)", []string{"m1"}, true}, + {"FILL(m1, LINEAR) * e2", []string{"m1", "e2"}, true}, + {"SEARCH('{App} MetricName=\"Latency\"', 'Average', 300)", nil, true}, + {"FILL(m1, BOGUS)", nil, false}, + {"m1 + REPEAT", nil, false}, + {"IF(m1)", nil, false}, + {"FILL(m1)", nil, false}, + {"NOPE(m1)", nil, false}, + {"SEARCH('{App}')", nil, false}, + {"SEARCH(' :aws.AccountId = 1 ', 'Sum')", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.expr, func(t *testing.T) { + got, ok := metricmath.References(tc.expr) + assert.Equal(t, tc.ok, ok) + assert.Equal(t, tc.want, got) + }) + } +} + +// searchMetrics is what the SEARCH tests can find. +func searchMetrics() ([]driver.MetricIdentifier, error) { + return []driver.MetricIdentifier{ + {Namespace: "AWS/EC2", MetricName: "CPUUtilization", Dimensions: map[string]string{"InstanceId": "i-1"}}, + {Namespace: "AWS/EC2", MetricName: "CPUUtilization", Dimensions: map[string]string{"InstanceId": "i-2"}}, + {Namespace: "AWS/EC2", MetricName: "CPUUtilization", Dimensions: map[string]string{"InstanceType": "t2.micro"}}, + {Namespace: "AWS/EC2", MetricName: "NetworkIn", Dimensions: map[string]string{"InstanceId": "i-1"}}, + {Namespace: "MyApp", MetricName: "CustomCount1"}, + {Namespace: "MyApp", MetricName: "Network-Errors-2"}, + {Namespace: "MyApp", MetricName: "SDBFailure"}, + }, nil +} + +// searchLabels runs expr as an entry with Period 300 and returns the labels +// of the series it finds and the metric reads it made. +func searchLabels(t *testing.T, expr string) ([]string, []driver.MetricStat) { + t.Helper() + + var fetched []driver.MetricStat + + fetch := func(ms *driver.MetricStat, p int) (metricmath.Series, error) { + read := *ms + read.Period = p + fetched = append(fetched, read) + + return metricmath.Series{Timestamps: at(0), Values: []float64{1}}, nil + } + + queries := []driver.MetricDataQuery{{ID: "e1", Expression: expr, Period: 300}} + + got, err := metricmath.New(queries, fetch).WithSearch(searchMetrics).ResolveAll("e1") + require.NoError(t, err) + + labels := make([]string, 0, len(got)) + for _, l := range got { + labels = append(labels, l.Label) + } + + return labels, fetched +} + +// TestSEARCH checks the matching rules of the search expression syntax +// page: the metric schema, partial and exact matches, designators and the +// boolean operators. +func TestSEARCH(t *testing.T) { + tests := []struct { + name string + expr string + want []string + }{ + {"schema and designator", `SEARCH('{AWS/EC2,InstanceId} MetricName="CPUUtilization"', 'Average')`, + []string{"i-1 CPUUtilization", "i-2 CPUUtilization"}}, + {"schema is the exact dimension set", `SEARCH('{AWS/EC2,InstanceType} MetricName="CPUUtilization"', 'Average')`, + []string{"t2.micro CPUUtilization"}}, + {"schema only", `SEARCH('{MyApp}', 'Sum')`, + []string{"CustomCount1", "Network-Errors-2", "SDBFailure"}}, + {"schema with space separator", `SEARCH('{AWS/EC2 InstanceType}', 'Sum')`, []string{"t2.micro CPUUtilization"}}, + {"designator partial", `SEARCH('InstanceType=micro', 'Average')`, []string{"t2.micro CPUUtilization"}}, + {"designator exact", `SEARCH('InstanceType="t2.micro"', 'Average')`, []string{"t2.micro CPUUtilization"}}, + {"partial token any case", `SEARCH('count', 'Sum')`, []string{"CustomCount1"}}, + {"partial token upper", `SEARCH('COUNT', 'Sum')`, []string{"CustomCount1"}}, + {"mixed case is split", `SEARCH('couNT', 'Sum')`, []string{}}, + {"composite token is case sensitive", `SEARCH('CustomCount', 'Sum')`, []string{"CustomCount1"}}, + {"composite with digit", `SEARCH('Count1', 'Sum')`, []string{"CustomCount1"}}, + {"lowercase composite misses", `SEARCH('customcount', 'Sum')`, []string{}}, + {"lowercase composite with digit misses", `SEARCH('count1', 'Sum')`, []string{}}, + {"acronym token", `SEARCH('sdb', 'Sum')`, []string{"SDBFailure"}}, + {"whole token", `SEARCH('sdbfailure', 'Sum')`, []string{"SDBFailure"}}, + {"delimited words", `SEARCH('network/errors', 'Sum')`, []string{"Network-Errors-2"}}, + {"underscore words", `SEARCH('Network_Errors', 'Sum')`, []string{"Network-Errors-2"}}, + {"exact match", `SEARCH(' "CustomCount1" ', 'Sum')`, []string{"CustomCount1"}}, + {"exact match is case sensitive", `SEARCH(' "customcount1" ', 'Sum')`, []string{}}, + {"exact match is whole", `SEARCH(' "Custom" ', 'Sum')`, []string{}}, + {"NOT", `SEARCH('{AWS/EC2,InstanceId} MetricName="CPUUtilization" NOT i-2', 'Average')`, + []string{"i-1 CPUUtilization"}}, + {"NOT namespace", `SEARCH('NOT Namespace=AWS', 'Sum')`, + []string{"CustomCount1", "Network-Errors-2", "SDBFailure"}}, + {"OR with groups", `SEARCH('{AWS/EC2,InstanceId} MetricName="NetworkIn" OR (MetricName="CPUUtilization" AND i-2)', 'Sum')`, + []string{"i-2 CPUUtilization", "i-1 NetworkIn"}}, + {"implicit AND", `SEARCH('{AWS/EC2,InstanceId} i-1 CPUUtilization', 'Sum')`, []string{"i-1 CPUUtilization"}}, + {"comma after schema", `SEARCH('{AWS/EC2, InstanceId}, MetricName="NetworkIn"', 'Sum')`, []string{"i-1 NetworkIn"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + labels, _ := searchLabels(t, tc.expr) + assert.Equal(t, tc.want, labels) + }) + } +} + +// TestSEARCHReadsEachMetric checks a found metric is read with the search's +// statistic and period, and with its exact dimensions. +func TestSEARCHReadsEachMetric(t *testing.T) { + _, fetched := searchLabels(t, `SEARCH('{AWS/EC2,InstanceId} MetricName="CPUUtilization"', 'Maximum', 120)`) + require.Len(t, fetched, 2) + assert.Equal(t, "Maximum", fetched[0].Stat) + assert.Equal(t, 120, fetched[0].Period) + assert.Equal(t, map[string]string{"InstanceId": "i-1"}, fetched[0].Dimensions) + + // Without a period argument the search reads at the entry's Period. + _, fetched = searchLabels(t, `SEARCH('{AWS/EC2,InstanceId} MetricName="CPUUtilization"', 'Sum')`) + require.Len(t, fetched, 2) + assert.Equal(t, 300, fetched[0].Period) +} + +// TestFILLOverSEARCH fills each series a search returns and keeps its label. +func TestFILLOverSEARCH(t *testing.T) { + queries := []driver.MetricDataQuery{{ID: "e1", Period: 60, + Expression: `FILL(SEARCH('{App,Host} MetricName="Hits"', 'Sum'), 0)`}} + fetch := func(ms *driver.MetricStat, _ int) (metricmath.Series, error) { + if ms.Dimensions["Host"] == "a" { + return metricmath.Series{Timestamps: at(1), Values: []float64{4}}, nil + } + + return metricmath.Series{Timestamps: at(2), Values: []float64{9}}, nil + } + + ev := metricmath.New(queries, fetch).WithRange(base, base.Add(3*time.Minute)). + WithSearch(func() ([]driver.MetricIdentifier, error) { + return []driver.MetricIdentifier{ + {Namespace: "App", MetricName: "Hits", Dimensions: map[string]string{"Host": "a"}}, + {Namespace: "App", MetricName: "Hits", Dimensions: map[string]string{"Host": "b"}}, + }, nil + }) + + got, err := ev.ResolveAll("e1") + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "a Hits", got[0].Label) + assert.Equal(t, []float64{0, 4, 0}, got[0].Values) + assert.Equal(t, "b Hits", got[1].Label) + assert.Equal(t, []float64{0, 0, 9}, got[1].Values) + + // An expression that returns several series has no single-series value. + s, err := ev.Resolve("e1") + require.NoError(t, err) + assert.Empty(t, s.Values) +} diff --git a/services/monitoring/metricmath/metricmath.go b/services/monitoring/metricmath/metricmath.go index 993b7b558..d53eee58c 100644 --- a/services/monitoring/metricmath/metricmath.go +++ b/services/monitoring/metricmath/metricmath.go @@ -1,8 +1,9 @@ // Package metricmath evaluates CloudWatch metric-math query lists. A list // holds MetricStat entries, which read one metric, and Expression entries, -// which combine other entries by ID with + - * / and parentheses. GetMetricData -// and metric-math alarms both use it, so the wire layer and the provider -// compute the same series. +// which combine other entries by ID with + - * /, parentheses, comparison and +// logical operators, and the IF, FILL and SEARCH functions. GetMetricData and +// metric-math alarms both use it, so the wire layer and the provider compute +// the same series. // // ANOMALY_DETECTION_BAND(id, k) is supported as a whole expression. It is an // approximation of the AWS model: mean -/+ k standard deviations of the @@ -38,10 +39,12 @@ type Evaluator struct { queries []driver.MetricDataQuery byID map[string]*driver.MetricDataQuery fetch Fetcher - memo map[memoKey]Series + memo map[memoKey]result inProgress map[string]bool band BandConfig history *Evaluator + start, end time.Time + lister Lister } // New returns an evaluator over queries that reads metrics through fetch. @@ -55,57 +58,99 @@ func New(queries []driver.MetricDataQuery, fetch Fetcher) *Evaluator { queries: queries, byID: byID, fetch: fetch, - memo: make(map[memoKey]Series, len(queries)), + memo: make(map[memoKey]result, len(queries)), inProgress: make(map[string]bool, len(queries)), } } +// WithRange sets the time range the metrics are read over, [start, end). +// FILL fills the periods of this range that have no value, so without it +// FILL leaves its input as is. It returns e. +func (e *Evaluator) WithRange(start, end time.Time) *Evaluator { + e.start, e.end = start, end + + return e +} + +// WithSearch sets where SEARCH finds metrics. Without it a SEARCH finds +// nothing. It returns e. +func (e *Evaluator) WithSearch(l Lister) *Evaluator { + e.lister = l + + return e +} + // Resolve returns the series of the entry with this ID. A fetch error is // returned. An unknown ID or an expression outside the supported syntax -// gives an empty series. +// gives an empty series, and so does an expression that returns several +// series, such as a SEARCH that finds more than one metric. func (e *Evaluator) Resolve(id string) (Series, error) { - return e.resolve(id, 0) + r, err := e.resolve(id, 0) + if err != nil { + return Series{}, err + } + + return r.asSeries(), nil +} + +// ResolveAll returns every series of the entry with this ID: one for a +// metric or a single-series expression, and one per found metric for an +// expression that returns an array of series, such as a SEARCH. +func (e *Evaluator) ResolveAll(id string) ([]Labeled, error) { + r, err := e.resolve(id, 0) + if err != nil { + return nil, err + } + + return r.labeled(), nil } // ResolveAt is Resolve with every metric read at period seconds. An alarm // uses it so each point fills exactly one of its evaluation periods. func (e *Evaluator) ResolveAt(id string, period int) (Series, error) { - return e.resolve(id, period) + r, err := e.resolve(id, period) + if err != nil { + return Series{}, err + } + + return r.asSeries(), nil } // resolve reads the entry at period. Zero means the entry's own period. -func (e *Evaluator) resolve(id string, period int) (Series, error) { +func (e *Evaluator) resolve(id string, period int) (result, error) { key := memoKey{id: id, period: period} - if s, ok := e.memo[key]; ok { - return s, nil + if r, ok := e.memo[key]; ok { + return r, nil } q, ok := e.byID[id] if !ok || e.inProgress[id] { - return Series{}, nil + return result{}, nil } e.inProgress[id] = true defer delete(e.inProgress, id) - s, err := e.resolveQuery(q, period) + r, err := e.resolveQuery(q, period) if err != nil { - return Series{}, err + return result{}, err } - e.memo[key] = s + e.memo[key] = r - return s, nil + return r, nil } -func (e *Evaluator) resolveQuery(q *driver.MetricDataQuery, period int) (Series, error) { +func (e *Evaluator) resolveQuery(q *driver.MetricDataQuery, period int) (result, error) { switch { case q.MetricStat != nil: if period == 0 { period = q.MetricStat.Period } - return e.fetch(q.MetricStat, period) + s, err := e.fetch(q.MetricStat, period) + + return result{series: s}, err case q.Expression != "": // An expression's own Period sets the granularity of everything it // reads. Without one it passes on the period it was asked for. @@ -113,24 +158,35 @@ func (e *Evaluator) resolveQuery(q *driver.MetricDataQuery, period int) (Series, period = q.Period } - return e.evalExpression(q.Expression, period) + return e.evalExpression(q, period) default: - return Series{}, nil + return result{}, nil } } -func (e *Evaluator) evalExpression(expr string, period int) (Series, error) { - n, ok := parseExpression(expr) +func (e *Evaluator) evalExpression(q *driver.MetricDataQuery, period int) (result, error) { + n, ok := parseExpression(q.Expression) if !ok { - return Series{}, nil + return result{}, nil + } + + // The expression's points are period seconds apart, or, when the + // entries keep their own periods, as far apart as the points it reads. + grid := period + if grid == 0 { + grid = Period(e.queries, q) } - res, err := n.evaluate(scope{e: e, period: period}) + res, err := n.evaluate(scope{e: e, period: period, gridPeriod: grid}) if err != nil { - return Series{}, err + return result{}, err + } + + if !res.isArray { + res = result{series: res.asSeries()} } - return res.asSeries(), nil + return res, nil } // References returns the IDs an expression reads. ok is false when the diff --git a/services/monitoring/metricmath/metricmath_test.go b/services/monitoring/metricmath/metricmath_test.go index 16cc6f9c7..5427aa159 100644 --- a/services/monitoring/metricmath/metricmath_test.go +++ b/services/monitoring/metricmath/metricmath_test.go @@ -173,7 +173,7 @@ func TestReferences(t *testing.T) { require.True(t, ok) assert.Equal(t, []string{"m1"}, ids) - _, ok = metricmath.References("FILL(m1, 0)") + _, ok = metricmath.References("RATE(m1)") assert.False(t, ok) _, ok = metricmath.References("m1 +") diff --git a/services/monitoring/metricmath/parser.go b/services/monitoring/metricmath/parser.go index a6e193924..711e106eb 100644 --- a/services/monitoring/metricmath/parser.go +++ b/services/monitoring/metricmath/parser.go @@ -1,9 +1,10 @@ package metricmath -// A small recursive-descent parser for the supported metric-math syntax: -// numbers, references to other entry IDs, the binary operators + - * /, -// unary minus and parentheses. ANOMALY_DETECTION_BAND(id[, k]) is also -// accepted when it is the whole expression. +// A recursive-descent parser for the supported metric-math syntax: numbers, +// references to other entry IDs, the arithmetic operators + - * /, unary +// minus, parentheses, the comparison operators == != < <= > >=, the logical +// operators AND (&&) and OR (||), and the functions IF, FILL and SEARCH. +// ANOMALY_DETECTION_BAND(id[, k]) is accepted when it is the whole expression. import ( "errors" @@ -24,18 +25,29 @@ func parseExpression(expr string) (n node, ok bool) { p := &mathParser{tokens: tokens} n, err := p.parse() - if err != nil || !p.atEnd() { + if err != nil || !p.atEnd() || !keywordsPlaced(n) { return nil, false } return n, true } +// Token kinds that are not a single operator rune. +const ( + tokNumber = 'n' + tokIdent = 'i' + tokString = 's' + tokCompare = 'c' + tokAnd = '&' + tokOr = '|' + tokInvalid = '?' +) + // mathToken is one lexical unit of an expression. type mathToken struct { - kind byte // 'n' number, 'i' ident, or an operator/paren rune + kind byte // one of the tok kinds, or an operator/paren/comma rune num float64 - ident string + ident string // identifier name, string literal body or comparison operator } func tokenizeMath(expr string) []mathToken { @@ -50,6 +62,14 @@ func tokenizeMath(expr string) []mathToken { case isOperatorOrParen(c): tokens = append(tokens, mathToken{kind: c}) i++ + case isComparisonStart(c) || c == '&' || c == '|': + tok, next := lexSymbol(expr, i) + tokens = append(tokens, tok) + i = next + case c == '\'': + tok, next := lexString(expr, i) + tokens = append(tokens, tok) + i = next case isNumberStart(c): tok, next := lexNumber(expr, i) tokens = append(tokens, tok) @@ -60,7 +80,7 @@ func tokenizeMath(expr string) []mathToken { i = next default: // Unknown character: emit a sentinel the parser rejects. - tokens = append(tokens, mathToken{kind: '?'}) + tokens = append(tokens, mathToken{kind: tokInvalid}) i++ } } @@ -68,6 +88,52 @@ func tokenizeMath(expr string) []mathToken { return tokens } +// lexSymbol reads a comparison or logical operator. +func lexSymbol(expr string, start int) (tok mathToken, next int) { + two := "" + if start+1 < len(expr) { + two = expr[start : start+2] + } + + switch two { + case "==", "!=", "<=", ">=": + return mathToken{kind: tokCompare, ident: two}, start + 2 + case "&&": + return mathToken{kind: tokAnd}, start + 2 + case "||": + return mathToken{kind: tokOr}, start + 2 + } + + switch expr[start] { + case '<', '>': + return mathToken{kind: tokCompare, ident: expr[start : start+1]}, start + 1 + default: + return mathToken{kind: tokInvalid}, start + 1 + } +} + +// lexString reads a single-quoted string. A backslash escapes the next +// character, so a SEARCH term can hold a quote. +func lexString(expr string, start int) (tok mathToken, next int) { + var body []byte + + for i := start + 1; i < len(expr); i++ { + switch expr[i] { + case '\\': + if i+1 < len(expr) { + body = append(body, expr[i], expr[i+1]) + i++ + } + case '\'': + return mathToken{kind: tokString, ident: string(body)}, i + 1 + default: + body = append(body, expr[i]) + } + } + + return mathToken{kind: tokInvalid}, len(expr) +} + func lexNumber(expr string, start int) (tok mathToken, next int) { i := start for i < len(expr) && isNumberStart(expr[i]) { @@ -76,10 +142,10 @@ func lexNumber(expr string, start int) (tok mathToken, next int) { val, err := strconv.ParseFloat(expr[start:i], 64) if err != nil { - return mathToken{kind: '?'}, i + return mathToken{kind: tokInvalid}, i } - return mathToken{kind: 'n', num: val}, i + return mathToken{kind: tokNumber, num: val}, i } func lexIdent(expr string, start int) (tok mathToken, next int) { @@ -88,17 +154,29 @@ func lexIdent(expr string, start int) (tok mathToken, next int) { i++ } - return mathToken{kind: 'i', ident: expr[start:i]}, i + word := expr[start:i] + + // AND and OR are the logical operators. IDs start with a lowercase + // letter, so they never clash with an entry. + switch word { + case "AND": + return mathToken{kind: tokAnd}, i + case "OR": + return mathToken{kind: tokOr}, i + } + + return mathToken{kind: tokIdent, ident: word}, i } -func isSpace(c byte) bool { return c == ' ' || c == '\t' } +func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } func isOperatorOrParen(c byte) bool { return c == '+' || c == '-' || c == '*' || c == '/' || c == '(' || c == ')' || c == ',' } -func isDigit(c byte) bool { return c >= '0' && c <= '9' } -func isNumberStart(c byte) bool { return isDigit(c) || c == '.' } -func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } -func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) } +func isComparisonStart(c byte) bool { return c == '=' || c == '!' || c == '<' || c == '>' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isNumberStart(c byte) bool { return isDigit(c) || c == '.' } +func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } +func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) } // mathParser is a recursive-descent parser over a token slice. type mathParser struct { @@ -116,12 +194,94 @@ func (p *mathParser) peek() (mathToken, bool) { return p.tokens[p.pos], true } +// peekKind reports whether the next token has this kind. +func (p *mathParser) peekKind(kind byte) bool { + tok, ok := p.peek() + + return ok && tok.kind == kind +} + +// expect consumes a token of this kind. +func (p *mathParser) expect(kind byte) error { + if !p.peekKind(kind) { + return errMathParse + } + + p.pos++ + + return nil +} + func (p *mathParser) parse() (node, error) { if len(p.tokens) == 0 { return nil, errMathParse } - return p.parseExpr() + return p.parseOr() +} + +// parseOr: or := and (OR and)*. OR binds loosest. +func (p *mathParser) parseOr() (node, error) { + n, err := p.parseAnd() + if err != nil { + return nil, err + } + + for p.peekKind(tokOr) { + p.pos++ + + right, rerr := p.parseAnd() + if rerr != nil { + return nil, rerr + } + + n = logicNode{and: false, left: n, right: right} + } + + return n, nil +} + +// parseAnd: and := compare (AND compare)*. +func (p *mathParser) parseAnd() (node, error) { + n, err := p.parseCompare() + if err != nil { + return nil, err + } + + for p.peekKind(tokAnd) { + p.pos++ + + right, rerr := p.parseCompare() + if rerr != nil { + return nil, rerr + } + + n = logicNode{and: true, left: n, right: right} + } + + return n, nil +} + +// parseCompare: compare := expr (cmp expr)?. +func (p *mathParser) parseCompare() (node, error) { + n, err := p.parseExpr() + if err != nil { + return nil, err + } + + tok, ok := p.peek() + if !ok || tok.kind != tokCompare { + return n, nil + } + + p.pos++ + + right, err := p.parseExpr() + if err != nil { + return nil, err + } + + return compareNode{op: tok.ident, left: n, right: right}, nil } func (p *mathParser) parseExpr() (node, error) { @@ -197,33 +357,182 @@ func (p *mathParser) parsePrimary() (node, error) { } switch tok.kind { - case 'n': + case tokNumber: p.pos++ return numberNode{val: tok.num}, nil - case 'i': + case tokIdent: p.pos++ + + if p.peekKind('(') { + return p.parseCall(tok.ident) + } + + if !isLowerStart(tok.ident) { + // An uppercase word outside a call is a keyword such as REPEAT, + // which only FILL reads. + return keywordNode{word: tok.ident}, nil + } + return refNode{id: tok.ident}, nil case '(': p.pos++ - inner, err := p.parseExpr() + inner, err := p.parseOr() if err != nil { return nil, err } - closing, has := p.peek() - if !has || closing.kind != ')' { - return nil, errMathParse + if err := p.expect(')'); err != nil { + return nil, err + } + + return inner, nil + default: + return nil, errMathParse + } +} + +// isLowerStart reports whether an identifier can name an entry: IDs start +// with a lowercase letter. +func isLowerStart(id string) bool { + return id != "" && id[0] >= 'a' && id[0] <= 'z' +} + +// parseCall parses the argument list of a function call and builds its node. +func (p *mathParser) parseCall(name string) (node, error) { + if err := p.expect('('); err != nil { + return nil, err + } + + var args []node + + if !p.peekKind(')') { + for { + arg, err := p.parseArg() + if err != nil { + return nil, err + } + + args = append(args, arg) + + if !p.peekKind(',') { + break + } + + p.pos++ } + } + if err := p.expect(')'); err != nil { + return nil, err + } + + return buildCall(name, args) +} + +// parseArg parses one call argument: a string literal or an expression. +func (p *mathParser) parseArg() (node, error) { + if tok, ok := p.peek(); ok && tok.kind == tokString { p.pos++ + return stringNode{val: tok.ident}, nil + } - return inner, nil + return p.parseOr() +} + +// Function names and keywords of the supported metric-math functions. +const ( + fnIf = "IF" + fnFill = "FILL" + fnSearch = "SEARCH" + kwRepeat = "REPEAT" + kwLinear = "LINEAR" + ifMinArgs = 2 + ifMaxArgs = 3 + fillArgs = 2 +) + +// Argument counts of SEARCH: the term and the statistic, then an optional +// period. +const ( + searchMinArgs = 2 + searchMaxArgs = 3 +) + +// buildCall checks a call's arguments and returns its node. +func buildCall(name string, args []node) (node, error) { + switch name { + case fnIf: + if len(args) < ifMinArgs || len(args) > ifMaxArgs || hasString(args) { + return nil, errMathParse + } + + n := ifNode{cond: args[0], then: args[1]} + if len(args) == ifMaxArgs { + n.otherwise = args[2] + } + + return n, nil + case fnFill: + if len(args) != fillArgs || hasString(args) { + return nil, errMathParse + } + + if kw, ok := args[1].(keywordNode); ok && kw.word != kwRepeat && kw.word != kwLinear { + return nil, errMathParse + } + + return fillNode{input: args[0], with: args[1]}, nil + case fnSearch: + return buildSearch(args) default: return nil, errMathParse } } +// buildSearch checks SEARCH('term', 'Stat'[, period]). +func buildSearch(args []node) (node, error) { + if len(args) < searchMinArgs || len(args) > searchMaxArgs { + return nil, errMathParse + } + + term, ok1 := args[0].(stringNode) + stat, ok2 := args[1].(stringNode) + + if !ok1 || !ok2 || stat.val == "" { + return nil, errMathParse + } + + q, ok := parseSearchQuery(term.val) + if !ok { + return nil, errMathParse + } + + n := searchNode{query: q, stat: stat.val} + + if len(args) == searchMaxArgs { + num, ok := args[2].(numberNode) + if !ok || num.val <= 0 || num.val != float64(int(num.val)) { + return nil, errMathParse + } + + n.period = int(num.val) + } + + return n, nil +} + +// hasString reports whether a string literal is used where a value belongs. +func hasString(args []node) bool { + for _, a := range args { + if _, ok := a.(stringNode); ok { + return true + } + } + + return false +} + // bandFunction is the metric-math function that returns an anomaly band. const bandFunction = "ANOMALY_DETECTION_BAND" @@ -240,7 +549,8 @@ const ( // isBandCall reports whether t starts ANOMALY_DETECTION_BAND(id and ends // with a closing parenthesis. func isBandCall(t []mathToken) bool { - return t[0].kind == 'i' && t[0].ident == bandFunction && t[1].kind == '(' && t[2].kind == 'i' && t[len(t)-1].kind == ')' + return t[0].kind == tokIdent && t[0].ident == bandFunction && t[1].kind == '(' && t[2].kind == tokIdent && + t[len(t)-1].kind == ')' } // parseBand matches ANOMALY_DETECTION_BAND(id) and ANOMALY_DETECTION_BAND(id, k). @@ -256,7 +566,7 @@ func parseBand(t []mathToken) (bandNode, bool) { n := bandNode{input: t[2].ident, k: defaultBandWidth} if len(t) == bandCallTokensWithK { - if t[3].kind != ',' || t[4].kind != 'n' { + if t[3].kind != ',' || t[4].kind != tokNumber { return bandNode{}, false } diff --git a/services/monitoring/metricmath/search.go b/services/monitoring/metricmath/search.go new file mode 100644 index 000000000..e17effd33 --- /dev/null +++ b/services/monitoring/metricmath/search.go @@ -0,0 +1,586 @@ +package metricmath + +// SEARCH(' {Namespace, DimensionName, ...} SearchTerm ', 'Statistic'[, period]) +// returns one series per metric that matches. The rules follow the +// CloudWatch user guide: +// https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/search-expression-syntax.html +// +// - The metric schema in braces is optional. A metric matches it when its +// namespace is the schema's namespace and its dimension names are exactly +// the schema's dimension names. +// - A bare word is a partial match on tokens of the namespace, the metric +// name, a dimension name or a dimension value. A double-quoted string is +// an exact match on one of them. +// - Name=value (MetricName, Namespace or a dimension name) limits the match +// to that property. +// - AND, OR and NOT are case sensitive, terms with no operator between them +// are joined with AND, and parentheses group. +// +// The cross-account :aws.AccountId designator is not supported, so a search +// that uses it is outside the supported syntax. + +import ( + "sort" + "strings" + "unicode" + + "github.com/stackshy/cloudemu/v2/services/monitoring/driver" +) + +// searchQuery is a parsed search string. +type searchQuery struct { + schema *metricSchema + term searchTerm // nil matches every metric of the schema +} + +// metricSchema is the {Namespace, DimensionName, ...} part. +type metricSchema struct { + namespace string + dims []string +} + +// searchTerm is one node of the boolean search term. +type searchTerm interface { + matches(m *driver.MetricIdentifier) bool +} + +// searchNode is SEARCH(query, stat[, period]). +type searchNode struct { + query searchQuery + stat string + period int +} + +// Lister lists the metrics a SEARCH can find: those with data in the past +// two weeks. +type Lister func() ([]driver.MetricIdentifier, error) + +func (n searchNode) evaluate(s scope) (result, error) { + if s.e.lister == nil { + return result{isArray: true, array: []Labeled{}}, nil + } + + metrics, err := s.e.lister() + if err != nil { + return result{}, err + } + + period := n.period + if period == 0 { + period = s.gridPeriod + } + + out := []Labeled{} + + for i := range metrics { + m := &metrics[i] + if !n.query.matches(m) { + continue + } + + series, err := s.e.fetch(&driver.MetricStat{ + Namespace: m.Namespace, MetricName: m.MetricName, Dimensions: m.Dimensions, Period: period, Stat: n.stat, + }, period) + if err != nil { + return result{}, err + } + + out = append(out, Labeled{Label: searchLabel(m), Series: series}) + } + + return result{isArray: true, array: out}, nil +} + +// searchLabel is the label of one found metric: its dimension values in +// dimension-name order, then its metric name. The user guide does not state +// the label GetMetricData gives a SEARCH result, so this is the emulator's +// choice; a Label on the query is put in front of it. +func searchLabel(m *driver.MetricIdentifier) string { + names := make([]string, 0, len(m.Dimensions)) + for k := range m.Dimensions { + names = append(names, k) + } + + sort.Strings(names) + + parts := make([]string, 0, len(names)+1) + for _, k := range names { + parts = append(parts, m.Dimensions[k]) + } + + return strings.Join(append(parts, m.MetricName), " ") +} + +func (q searchQuery) matches(m *driver.MetricIdentifier) bool { + if q.schema != nil && !q.schema.matches(m) { + return false + } + + return q.term == nil || q.term.matches(m) +} + +func (sc *metricSchema) matches(m *driver.MetricIdentifier) bool { + if m.Namespace != sc.namespace || len(m.Dimensions) != len(sc.dims) { + return false + } + + for _, d := range sc.dims { + if _, ok := m.Dimensions[d]; !ok { + return false + } + } + + return true +} + +type andTerm struct{ left, right searchTerm } + +func (t andTerm) matches(m *driver.MetricIdentifier) bool { + return t.left.matches(m) && t.right.matches(m) +} + +type orTerm struct{ left, right searchTerm } + +func (t orTerm) matches(m *driver.MetricIdentifier) bool { + return t.left.matches(m) || t.right.matches(m) +} + +type notTerm struct{ inner searchTerm } + +func (t notTerm) matches(m *driver.MetricIdentifier) bool { return !t.inner.matches(m) } + +// valueTerm is an exact or partial match, on every property or on one. +type valueTerm struct { + property string // "" for any property + value string + exact bool +} + +// Property names a designator can use besides a dimension name. +const ( + propMetricName = "MetricName" + propNamespace = "Namespace" +) + +func (t valueTerm) matches(m *driver.MetricIdentifier) bool { + for _, field := range t.fields(m) { + if t.exact && field == t.value { + return true + } + + if !t.exact && partialMatch(t.value, field) { + return true + } + } + + return false +} + +// fields returns the strings the term is matched against. +func (t valueTerm) fields(m *driver.MetricIdentifier) []string { + switch t.property { + case "": + out := []string{m.Namespace, m.MetricName} + for k, v := range m.Dimensions { + out = append(out, k, v) + } + + return out + case propMetricName: + return []string{m.MetricName} + case propNamespace: + return []string{m.Namespace} + default: + if v, ok := m.Dimensions[t.property]; ok { + return []string{v} + } + + return nil + } +} + +// partialMatch reports whether a search word matches a field by tokens. A +// word that is a single token matches any token of the field, ignoring case, +// or the whole field with its delimiters removed. A word of several tokens +// matches tokens that appear one after another in the field. Within a +// delimiter-free part of the word made of several tokens, such as +// CustomCount, case must match; parts split by delimiters, such as +// network/errors, ignore case. +func partialMatch(word, field string) bool { + want := wordTokens(word) + if len(want) == 0 { + return false + } + + have := splitTokens(field) + + if len(want) == 1 && strings.EqualFold(want[0].text, strings.Join(alnumParts(field), "")) { + return true + } + + for start := 0; start+len(want) <= len(have); start++ { + if tokensAt(have[start:], want) { + return true + } + } + + return false +} + +func tokensAt(have []string, want []wantToken) bool { + for i, w := range want { + if w.caseSensitive && have[i] != w.text { + return false + } + + if !w.caseSensitive && !strings.EqualFold(have[i], w.text) { + return false + } + } + + return true +} + +// wantToken is one token of a search word and whether case must match. +type wantToken struct { + text string + caseSensitive bool +} + +func wordTokens(word string) []wantToken { + var out []wantToken + + for _, part := range alnumParts(word) { + toks := camelTokens(part) + for _, t := range toks { + out = append(out, wantToken{text: t, caseSensitive: len(toks) > 1}) + } + } + + return out +} + +// splitTokens returns the tokens of a field, in order, in their own case. +func splitTokens(field string) []string { + var out []string + + for _, part := range alnumParts(field) { + out = append(out, camelTokens(part)...) + } + + return out +} + +// alnumParts splits s at every run of non-alphanumeric characters. +func alnumParts(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) +} + +// camelTokens splits an alphanumeric string where its case changes from +// lower to upper, before the last capital of a capital run that a lowercase +// letter follows (SDBFailure is SDB, Failure), and between letters and +// digits. +func camelTokens(s string) []string { + r := []rune(s) + + var ( + out []string + start int + ) + + for i := 1; i < len(r); i++ { + if tokenBoundary(r, i) { + out = append(out, string(r[start:i])) + start = i + } + } + + return append(out, string(r[start:])) +} + +func tokenBoundary(r []rune, i int) bool { + prev, cur := r[i-1], r[i] + + switch { + case unicode.IsDigit(prev) != unicode.IsDigit(cur): + return true + case unicode.IsLower(prev) && unicode.IsUpper(cur): + return true + case unicode.IsUpper(prev) && unicode.IsUpper(cur) && i+1 < len(r) && unicode.IsLower(r[i+1]): + return true + default: + return false + } +} + +// ---- search string parser ---- + +// Search token kinds. +const ( + sWord = 'w' + sQuoted = 'q' + sEquals = '=' + sOpen = '(' + sClose = ')' + sLBrace = '{' + sRBrace = '}' + sBad = '?' +) + +type searchToken struct { + kind byte + text string +} + +// parseSearchQuery parses a search string. ok is false when it is outside +// the supported syntax. +func parseSearchQuery(src string) (searchQuery, bool) { + toks, ok := lexSearch(src) + if !ok { + return searchQuery{}, false + } + + p := &searchParser{toks: toks} + + var q searchQuery + + if p.peek(sLBrace) { + sc, ok := p.parseSchema() + if !ok { + return searchQuery{}, false + } + + q.schema = sc + } + + if p.done() { + return q, q.schema != nil + } + + term, ok := p.parseOr() + if !ok || !p.done() { + return searchQuery{}, false + } + + q.term = term + + return q, true +} + +// lexSearch splits a search string into tokens. Commas and white space +// separate tokens. +func lexSearch(src string) ([]searchToken, bool) { + var out []searchToken + + for i := 0; i < len(src); { + c := src[i] + + switch { + case c == ' ' || c == '\t' || c == '\n' || c == ',': + i++ + case c == '=' || c == '(' || c == ')' || c == '{' || c == '}': + out = append(out, searchToken{kind: c}) + i++ + case c == '"': + text, next, ok := lexQuoted(src, i) + if !ok { + return nil, false + } + + out = append(out, searchToken{kind: sQuoted, text: text}) + i = next + default: + j := i + for j < len(src) && !strings.ContainsRune(" \t\n,=(){}\"", rune(src[j])) { + j++ + } + + word := src[i:j] + if strings.HasPrefix(word, ":") { + // :aws.AccountId and other designators are not supported. + return nil, false + } + + out = append(out, searchToken{kind: sWord, text: word}) + i = j + } + } + + return out, true +} + +// lexQuoted reads a double-quoted string. A backslash escapes the next +// character. +func lexQuoted(src string, start int) (text string, next int, ok bool) { + var b strings.Builder + + for i := start + 1; i < len(src); i++ { + switch src[i] { + case '\\': + if i+1 < len(src) { + i++ + b.WriteByte(src[i]) + } + case '"': + return b.String(), i + 1, true + default: + b.WriteByte(src[i]) + } + } + + return "", 0, false +} + +type searchParser struct { + toks []searchToken + pos int +} + +func (p *searchParser) done() bool { return p.pos >= len(p.toks) } + +func (p *searchParser) peek(kind byte) bool { + return !p.done() && p.toks[p.pos].kind == kind +} + +func (p *searchParser) peekWord(word string) bool { + return p.peek(sWord) && p.toks[p.pos].text == word +} + +// parseSchema reads {Namespace, DimensionName, ...}. +func (p *searchParser) parseSchema() (*metricSchema, bool) { + p.pos++ + + var names []string + + for !p.done() && (p.peek(sWord) || p.peek(sQuoted)) { + names = append(names, p.toks[p.pos].text) + p.pos++ + } + + if !p.peek(sRBrace) || len(names) == 0 { + return nil, false + } + + p.pos++ + + return &metricSchema{namespace: names[0], dims: names[1:]}, true +} + +func (p *searchParser) parseOr() (searchTerm, bool) { + left, ok := p.parseAnd() + if !ok { + return nil, false + } + + for p.peekWord("OR") { + p.pos++ + + right, ok := p.parseAnd() + if !ok { + return nil, false + } + + left = orTerm{left: left, right: right} + } + + return left, true +} + +// parseAnd joins terms with AND, written or implied. +func (p *searchParser) parseAnd() (searchTerm, bool) { + left, ok := p.parseUnary() + if !ok { + return nil, false + } + + for !p.done() && !p.peek(sClose) && !p.peekWord("OR") { + if p.peekWord("AND") { + p.pos++ + } + + right, ok := p.parseUnary() + if !ok { + return nil, false + } + + left = andTerm{left: left, right: right} + } + + return left, true +} + +func (p *searchParser) parseUnary() (searchTerm, bool) { + if p.peekWord("NOT") { + p.pos++ + + inner, ok := p.parseUnary() + if !ok { + return nil, false + } + + return notTerm{inner: inner}, true + } + + return p.parsePrimary() +} + +func (p *searchParser) parsePrimary() (searchTerm, bool) { + if p.done() { + return nil, false + } + + tok := p.toks[p.pos] + + switch tok.kind { + case sOpen: + p.pos++ + + inner, ok := p.parseOr() + if !ok || !p.peek(sClose) { + return nil, false + } + + p.pos++ + + return inner, true + case sQuoted: + p.pos++ + return valueTerm{value: tok.text, exact: true}, true + case sWord: + if tok.text == "AND" || tok.text == "OR" { + return nil, false + } + + p.pos++ + + if p.peek(sEquals) { + return p.parseDesignated(tok.text) + } + + return valueTerm{value: tok.text}, true + default: + return nil, false + } +} + +// parseDesignated reads the value after Name=. +func (p *searchParser) parseDesignated(property string) (searchTerm, bool) { + p.pos++ + + if p.done() { + return nil, false + } + + tok := p.toks[p.pos] + + switch tok.kind { + case sQuoted: + p.pos++ + return valueTerm{property: property, value: tok.text, exact: true}, true + case sWord: + p.pos++ + return valueTerm{property: property, value: tok.text}, true + default: + return nil, false + } +} From 9c619eee972de8a14e37a5eb304920dc16bca80d Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 10 Oct 2026 23:29:32 +0530 Subject: [PATCH 3/4] docs(aws-cloudwatch): record metric math and GetMetricWidgetImage non-goals GetMetricWidgetImage renders a PNG graph, which the emulator does not do. Record it, the metric math functions that are evaluated, the labels the AWS docs leave unstated, and the newer AnomalyDetectorId surface as non-goals. Part of #1520 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/coverage/aws/cloudwatch.md | 4 +++- docs/coverage/azure/monitor.md | 4 +++- docs/coverage/gcp/cloudmonitoring.md | 4 +++- docs/coverage/nongoals/monitoring.md | 3 +++ docs/coverage/oci/monitoring.md | 4 +++- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 docs/coverage/nongoals/monitoring.md diff --git a/docs/coverage/aws/cloudwatch.md b/docs/coverage/aws/cloudwatch.md index 96699bf3b..d3d53c3b1 100644 --- a/docs/coverage/aws/cloudwatch.md +++ b/docs/coverage/aws/cloudwatch.md @@ -22,4 +22,6 @@ AWS's `monitoring` service · portable interface `driver.Monitoring` · [AWS ind ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- CloudWatch `GetMetricWidgetImage` is not implemented. It renders a metric graph as a PNG image, which is presentation, not control-plane state, and the emulator has no graph renderer. A call gets the unknown-operation error. +- CloudWatch metric math evaluates arithmetic, comparison and logical operators, `IF`, `FILL`, `SEARCH` and a whole-expression `ANOMALY_DETECTION_BAND`. Other functions (such as `SUM`, `METRICS`, `RATE`) are stored and return no data. The label of each `SEARCH` result (dimension values, then the metric name) and the labels of the two band rows are the emulator's choice, because the AWS docs do not state them. `FILL` with `REPEAT` or `LINEAR` leaves a gap before the first value (and, for `LINEAR`, after the last one) empty. +- The CloudWatch anomaly band is mean -/+ k standard deviations of the past two weeks, not AWS's trained model. `AnomalyDetectorId` and the `AnomalyDetectorIds` filter are not returned or accepted. diff --git a/docs/coverage/azure/monitor.md b/docs/coverage/azure/monitor.md index 0463014f6..1be8841c6 100644 --- a/docs/coverage/azure/monitor.md +++ b/docs/coverage/azure/monitor.md @@ -35,4 +35,6 @@ ActivityLogRecorder is an OPTIONAL capability, discovered by type assertion ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- CloudWatch `GetMetricWidgetImage` is not implemented. It renders a metric graph as a PNG image, which is presentation, not control-plane state, and the emulator has no graph renderer. A call gets the unknown-operation error. +- CloudWatch metric math evaluates arithmetic, comparison and logical operators, `IF`, `FILL`, `SEARCH` and a whole-expression `ANOMALY_DETECTION_BAND`. Other functions (such as `SUM`, `METRICS`, `RATE`) are stored and return no data. The label of each `SEARCH` result (dimension values, then the metric name) and the labels of the two band rows are the emulator's choice, because the AWS docs do not state them. `FILL` with `REPEAT` or `LINEAR` leaves a gap before the first value (and, for `LINEAR`, after the last one) empty. +- The CloudWatch anomaly band is mean -/+ k standard deviations of the past two weeks, not AWS's trained model. `AnomalyDetectorId` and the `AnomalyDetectorIds` filter are not returned or accepted. diff --git a/docs/coverage/gcp/cloudmonitoring.md b/docs/coverage/gcp/cloudmonitoring.md index b28ba9cbb..1ac1421a8 100644 --- a/docs/coverage/gcp/cloudmonitoring.md +++ b/docs/coverage/gcp/cloudmonitoring.md @@ -22,4 +22,6 @@ GCP's `monitoring` service · portable interface `driver.Monitoring` · [GCP ind ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- CloudWatch `GetMetricWidgetImage` is not implemented. It renders a metric graph as a PNG image, which is presentation, not control-plane state, and the emulator has no graph renderer. A call gets the unknown-operation error. +- CloudWatch metric math evaluates arithmetic, comparison and logical operators, `IF`, `FILL`, `SEARCH` and a whole-expression `ANOMALY_DETECTION_BAND`. Other functions (such as `SUM`, `METRICS`, `RATE`) are stored and return no data. The label of each `SEARCH` result (dimension values, then the metric name) and the labels of the two band rows are the emulator's choice, because the AWS docs do not state them. `FILL` with `REPEAT` or `LINEAR` leaves a gap before the first value (and, for `LINEAR`, after the last one) empty. +- The CloudWatch anomaly band is mean -/+ k standard deviations of the past two weeks, not AWS's trained model. `AnomalyDetectorId` and the `AnomalyDetectorIds` filter are not returned or accepted. diff --git a/docs/coverage/nongoals/monitoring.md b/docs/coverage/nongoals/monitoring.md new file mode 100644 index 000000000..b27a48a48 --- /dev/null +++ b/docs/coverage/nongoals/monitoring.md @@ -0,0 +1,3 @@ +- CloudWatch `GetMetricWidgetImage` is not implemented. It renders a metric graph as a PNG image, which is presentation, not control-plane state, and the emulator has no graph renderer. A call gets the unknown-operation error. +- CloudWatch metric math evaluates arithmetic, comparison and logical operators, `IF`, `FILL`, `SEARCH` and a whole-expression `ANOMALY_DETECTION_BAND`. Other functions (such as `SUM`, `METRICS`, `RATE`) are stored and return no data. The label of each `SEARCH` result (dimension values, then the metric name) and the labels of the two band rows are the emulator's choice, because the AWS docs do not state them. `FILL` with `REPEAT` or `LINEAR` leaves a gap before the first value (and, for `LINEAR`, after the last one) empty. +- The CloudWatch anomaly band is mean -/+ k standard deviations of the past two weeks, not AWS's trained model. `AnomalyDetectorId` and the `AnomalyDetectorIds` filter are not returned or accepted. diff --git a/docs/coverage/oci/monitoring.md b/docs/coverage/oci/monitoring.md index 4bea94029..37761d57d 100644 --- a/docs/coverage/oci/monitoring.md +++ b/docs/coverage/oci/monitoring.md @@ -22,4 +22,6 @@ OCI's `monitoring` service · portable interface `driver.Monitoring` · [OCI ind ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- CloudWatch `GetMetricWidgetImage` is not implemented. It renders a metric graph as a PNG image, which is presentation, not control-plane state, and the emulator has no graph renderer. A call gets the unknown-operation error. +- CloudWatch metric math evaluates arithmetic, comparison and logical operators, `IF`, `FILL`, `SEARCH` and a whole-expression `ANOMALY_DETECTION_BAND`. Other functions (such as `SUM`, `METRICS`, `RATE`) are stored and return no data. The label of each `SEARCH` result (dimension values, then the metric name) and the labels of the two band rows are the emulator's choice, because the AWS docs do not state them. `FILL` with `REPEAT` or `LINEAR` leaves a gap before the first value (and, for `LINEAR`, after the last one) empty. +- The CloudWatch anomaly band is mean -/+ k standard deviations of the past two weeks, not AWS's trained model. `AnomalyDetectorId` and the `AnomalyDetectorIds` filter are not returned or accepted. From 75b7f24da8758772f318d9df9dca1c1efad32af6 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sun, 11 Oct 2026 11:27:18 +0530 Subject: [PATCH 4/4] refactor(aws-cloudwatch): lint cleanups in metric math Pointer params for the 96-byte result, operator and keyword constants, smaller parser and search lexer functions, and drop the nolint:dupl on deleteMetricStream that the dashboard change made unused. Part of #1520 Co-Authored-By: Claude Opus 5.5 (1M context) --- server/aws/cloudwatch/handler.go | 1 - server/aws/cloudwatch/metric_streams.go | 9 +- services/monitoring/metricmath/eval.go | 42 +++---- services/monitoring/metricmath/functions.go | 18 +-- services/monitoring/metricmath/parser.go | 118 +++++++++++++------- services/monitoring/metricmath/search.go | 78 +++++++------ 6 files changed, 152 insertions(+), 114 deletions(-) diff --git a/server/aws/cloudwatch/handler.go b/server/aws/cloudwatch/handler.go index f4ced9d0f..f6b8dab20 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -282,7 +282,6 @@ func faultOf(status int) string { } func writeCBORErrorBody(w http.ResponseWriter, status int, errType, msg string) { - payload := map[string]any{ "__type": errType, "message": msg, diff --git a/server/aws/cloudwatch/metric_streams.go b/server/aws/cloudwatch/metric_streams.go index 70b831d8e..9a4ca6ff0 100644 --- a/server/aws/cloudwatch/metric_streams.go +++ b/server/aws/cloudwatch/metric_streams.go @@ -240,13 +240,8 @@ type deleteMetricStreamInput struct { Name string `cbor:"Name"` } -// deleteMetricStream is structurally identical to deleteDashboards (unmarshal -// a single-field input, call the matching AWS-local store method, write an -// empty success response). The two resources' delete semantics really do -// share this shape, so the duplication is inherent rather than a missed -// abstraction. -// -//nolint:dupl // structurally identical to deleteDashboards; see comment above. +// deleteMetricStream deletes one metric stream and writes an empty success +// response. func (h *Handler) deleteMetricStream(w http.ResponseWriter, r *http.Request, body []byte) { store, ok := h.monitoring.(metricStreamStore) if !ok { diff --git a/services/monitoring/metricmath/eval.go b/services/monitoring/metricmath/eval.go index 363aafd48..e7f411fd8 100644 --- a/services/monitoring/metricmath/eval.go +++ b/services/monitoring/metricmath/eval.go @@ -30,7 +30,7 @@ func emptySeries() Series { // asSeries renders a result as a series. A scalar becomes a single point, so // a constant expression still returns a value row. An array has a single // series value only when it holds exactly one series. -func (r result) asSeries() Series { +func (r *result) asSeries() Series { switch { case r.isScalar: return Series{Timestamps: []time.Time{{}}, Values: []float64{r.scalar}} @@ -46,7 +46,7 @@ func (r result) asSeries() Series { } // labeled renders a result as the list of series it returns. -func (r result) labeled() []Labeled { +func (r *result) labeled() []Labeled { if r.isArray { return r.array } @@ -59,7 +59,8 @@ func mapArray(arr []Labeled, f func(result) result) result { out := make([]Labeled, 0, len(arr)) for _, l := range arr { - out = append(out, Labeled{Label: l.Label, Series: f(result{series: l.Series}).asSeries()}) + r := f(result{series: l.Series}) + out = append(out, Labeled{Label: l.Label, Series: r.asSeries()}) } return result{isArray: true, array: out} @@ -73,15 +74,15 @@ type pointFunc func(a, b float64) float64 // With union set, a timestamp only one series has counts as 0 in the other, // as metric-math comparison and logical operators do. Without it the point is // dropped. -func pointwise(f pointFunc, left, right result, union bool) result { +func pointwise(f pointFunc, left, right *result, union bool) result { switch { case left.isArray && right.isArray: // TS[] op TS[] is not a documented form. return result{series: emptySeries()} case left.isArray: - return mapArray(left.array, func(l result) result { return pointwise(f, l, right, union) }) + return mapArray(left.array, func(l result) result { return pointwise(f, &l, right, union) }) case right.isArray: - return mapArray(right.array, func(r result) result { return pointwise(f, left, r, union) }) + return mapArray(right.array, func(r result) result { return pointwise(f, left, &r, union) }) case left.isScalar && right.isScalar: return result{isScalar: true, scalar: f(left.scalar, right.scalar)} case left.isScalar: @@ -162,10 +163,13 @@ func mergeTimestamps(lists ...[]time.Time) []time.Time { for _, l := range lists { for _, ts := range l { - if !seen[ts.UnixNano()] { - seen[ts.UnixNano()] = true - out = append(out, ts) + if seen[ts.UnixNano()] { + continue } + + seen[ts.UnixNano()] = true + + out = append(out, ts) } } @@ -211,17 +215,17 @@ func truth(b bool) float64 { func comparison(op string) pointFunc { return func(a, b float64) float64 { switch op { - case "==": + case opEq: return truth(a == b) - case "!=": + case opNe: return truth(a != b) - case "<": + case opLt: return truth(a < b) - case "<=": + case opLe: return truth(a <= b) - case ">": + case opGt: return truth(a > b) - case ">=": + case opGe: return truth(a >= b) default: return 0 @@ -278,7 +282,7 @@ func (n negNode) evaluate(s scope) (result, error) { return result{}, err } - return pointwise(arithmetic('-'), result{isScalar: true}, v, false), nil + return pointwise(arithmetic('-'), &result{isScalar: true}, &v, false), nil } type binaryNode struct { @@ -292,7 +296,7 @@ func (n binaryNode) evaluate(s scope) (result, error) { return result{}, err } - return pointwise(arithmetic(n.op), left, right, false), nil + return pointwise(arithmetic(n.op), &left, &right, false), nil } // compareNode is a comparison. Each point is 1 when it holds and 0 when not. @@ -307,7 +311,7 @@ func (n compareNode) evaluate(s scope) (result, error) { return result{}, err } - return pointwise(comparison(n.op), left, right, true), nil + return pointwise(comparison(n.op), &left, &right, true), nil } // logicNode is AND or OR. A non-zero value is true. @@ -330,7 +334,7 @@ func (n logicNode) evaluate(s scope) (result, error) { return truth(a != 0 || b != 0) } - return pointwise(f, left, right, true), nil + return pointwise(f, &left, &right, true), nil } func evalPair(s scope, a, b node) (left, right result, err error) { diff --git a/services/monitoring/metricmath/functions.go b/services/monitoring/metricmath/functions.go index f98a7ee3a..c033d73e5 100644 --- a/services/monitoring/metricmath/functions.go +++ b/services/monitoring/metricmath/functions.go @@ -40,17 +40,17 @@ func (n ifNode) evaluate(s scope) (result, error) { } if cond.isScalar { - return ifScalar(cond.scalar, then, otherwise), nil + return ifScalar(cond.scalar, &then, otherwise), nil } - return result{series: ifSeries(cond.series, then, otherwise)}, nil + return result{series: ifSeries(cond.series, &then, otherwise)}, nil } // ifScalar is IF(S, a, b): a when S is true, b when false, and an empty // series when false and b is left out. -func ifScalar(cond float64, then result, otherwise *result) result { +func ifScalar(cond float64, then, otherwise *result) result { if cond != 0 { - return then + return *then } if otherwise == nil { @@ -69,12 +69,12 @@ func ifScalar(cond float64, then result, otherwise *result) result { // - A series branch with no point at that time gives 0, except that a // missing false-branch point gives no point when the true branch is a // scalar. This is the IF(metric1, scalar2, metric3) table of the guide. -func ifSeries(cond Series, then result, otherwise *result) Series { +func ifSeries(cond Series, then, otherwise *result) Series { thenAt := branchValues(then) var otherAt map[int64]float64 if otherwise != nil { - otherAt = branchValues(*otherwise) + otherAt = branchValues(otherwise) } out := emptySeries() @@ -88,7 +88,7 @@ func ifSeries(cond Series, then result, otherwise *result) Series { if cond.Values[i] != 0 { v, ok = branchValue(then, thenAt, ts, true) } else if otherwise != nil { - v, ok = branchValue(*otherwise, otherAt, ts, !then.isScalar) + v, ok = branchValue(otherwise, otherAt, ts, !then.isScalar) } if ok { @@ -100,7 +100,7 @@ func ifSeries(cond Series, then result, otherwise *result) Series { return out } -func branchValues(r result) map[int64]float64 { +func branchValues(r *result) map[int64]float64 { if r.isScalar { return nil } @@ -110,7 +110,7 @@ func branchValues(r result) map[int64]float64 { // branchValue reads one IF branch at ts. zeroIfMissing says whether a series // branch with no point there gives 0 or no point. -func branchValue(r result, at map[int64]float64, ts time.Time, zeroIfMissing bool) (float64, bool) { +func branchValue(r *result, at map[int64]float64, ts time.Time, zeroIfMissing bool) (float64, bool) { if r.isScalar { return r.scalar, true } diff --git a/services/monitoring/metricmath/parser.go b/services/monitoring/metricmath/parser.go index 711e106eb..ceef47b6d 100644 --- a/services/monitoring/metricmath/parser.go +++ b/services/monitoring/metricmath/parser.go @@ -88,25 +88,36 @@ func tokenizeMath(expr string) []mathToken { return tokens } +// Comparison and logical operator spellings. +const ( + opEq = "==" + opNe = "!=" + opLt = "<" + opLe = "<=" + opGt = ">" + opGe = ">=" + opAnd = "&&" + opOr = "||" +) + // lexSymbol reads a comparison or logical operator. func lexSymbol(expr string, start int) (tok mathToken, next int) { - two := "" - if start+1 < len(expr) { - two = expr[start : start+2] - } - - switch two { - case "==", "!=", "<=", ">=": - return mathToken{kind: tokCompare, ident: two}, start + 2 - case "&&": - return mathToken{kind: tokAnd}, start + 2 - case "||": - return mathToken{kind: tokOr}, start + 2 + if start+len(opEq) <= len(expr) { + two := expr[start : start+len(opEq)] + + switch two { + case opEq, opNe, opLe, opGe: + return mathToken{kind: tokCompare, ident: two}, start + len(two) + case opAnd: + return mathToken{kind: tokAnd}, start + len(two) + case opOr: + return mathToken{kind: tokOr}, start + len(two) + } } - switch expr[start] { - case '<', '>': - return mathToken{kind: tokCompare, ident: expr[start : start+1]}, start + 1 + switch one := expr[start : start+1]; one { + case opLt, opGt: + return mathToken{kind: tokCompare, ident: one}, start + 1 default: return mathToken{kind: tokInvalid}, start + 1 } @@ -159,9 +170,9 @@ func lexIdent(expr string, start int) (tok mathToken, next int) { // AND and OR are the logical operators. IDs start with a lowercase // letter, so they never clash with an entry. switch word { - case "AND": + case kwAnd: return mathToken{kind: tokAnd}, i - case "OR": + case kwOr: return mathToken{kind: tokOr}, i } @@ -447,6 +458,9 @@ const ( fnSearch = "SEARCH" kwRepeat = "REPEAT" kwLinear = "LINEAR" + kwAnd = "AND" + kwOr = "OR" + kwNot = "NOT" ifMinArgs = 2 ifMaxArgs = 3 fillArgs = 2 @@ -463,26 +477,9 @@ const ( func buildCall(name string, args []node) (node, error) { switch name { case fnIf: - if len(args) < ifMinArgs || len(args) > ifMaxArgs || hasString(args) { - return nil, errMathParse - } - - n := ifNode{cond: args[0], then: args[1]} - if len(args) == ifMaxArgs { - n.otherwise = args[2] - } - - return n, nil + return buildIf(args) case fnFill: - if len(args) != fillArgs || hasString(args) { - return nil, errMathParse - } - - if kw, ok := args[1].(keywordNode); ok && kw.word != kwRepeat && kw.word != kwLinear { - return nil, errMathParse - } - - return fillNode{input: args[0], with: args[1]}, nil + return buildFill(args) case fnSearch: return buildSearch(args) default: @@ -490,16 +487,43 @@ func buildCall(name string, args []node) (node, error) { } } +// buildIf checks IF(cond, a[, b]). +func buildIf(args []node) (node, error) { + if len(args) < ifMinArgs || len(args) > ifMaxArgs || hasString(args) { + return nil, errMathParse + } + + n := ifNode{cond: args[0], then: args[1]} + if len(args) == ifMaxArgs { + n.otherwise = args[2] + } + + return n, nil +} + +// buildFill checks FILL(x, S|TS|REPEAT|LINEAR). +func buildFill(args []node) (node, error) { + if len(args) != fillArgs || hasString(args) { + return nil, errMathParse + } + + if kw, ok := args[1].(keywordNode); ok && kw.word != kwRepeat && kw.word != kwLinear { + return nil, errMathParse + } + + return fillNode{input: args[0], with: args[1]}, nil +} + // buildSearch checks SEARCH('term', 'Stat'[, period]). func buildSearch(args []node) (node, error) { if len(args) < searchMinArgs || len(args) > searchMaxArgs { return nil, errMathParse } - term, ok1 := args[0].(stringNode) - stat, ok2 := args[1].(stringNode) + term, isTerm := args[0].(stringNode) + stat, isStat := args[1].(stringNode) - if !ok1 || !ok2 || stat.val == "" { + if !isTerm || !isStat || stat.val == "" { return nil, errMathParse } @@ -511,17 +535,27 @@ func buildSearch(args []node) (node, error) { n := searchNode{query: q, stat: stat.val} if len(args) == searchMaxArgs { - num, ok := args[2].(numberNode) - if !ok || num.val <= 0 || num.val != float64(int(num.val)) { + period, ok := searchPeriod(args[2]) + if !ok { return nil, errMathParse } - n.period = int(num.val) + n.period = period } return n, nil } +// searchPeriod reads the period argument of SEARCH: a positive whole number. +func searchPeriod(arg node) (int, bool) { + num, ok := arg.(numberNode) + if !ok || num.val <= 0 || num.val != float64(int(num.val)) { + return 0, false + } + + return int(num.val), true +} + // hasString reports whether a string literal is used where a value belongs. func hasString(args []node) bool { for _, a := range args { diff --git a/services/monitoring/metricmath/search.go b/services/monitoring/metricmath/search.go index e17effd33..e225b4bd8 100644 --- a/services/monitoring/metricmath/search.go +++ b/services/monitoring/metricmath/search.go @@ -345,8 +345,8 @@ func parseSearchQuery(src string) (searchQuery, bool) { var q searchQuery if p.peek(sLBrace) { - sc, ok := p.parseSchema() - if !ok { + sc, schemaOK := p.parseSchema() + if !schemaOK { return searchQuery{}, false } @@ -367,48 +367,54 @@ func parseSearchQuery(src string) (searchQuery, bool) { return q, true } +// searchDelimiters end a bare search word. +const searchDelimiters = " \t\n,=(){}\"" + // lexSearch splits a search string into tokens. Commas and white space // separate tokens. func lexSearch(src string) ([]searchToken, bool) { var out []searchToken for i := 0; i < len(src); { - c := src[i] - - switch { - case c == ' ' || c == '\t' || c == '\n' || c == ',': - i++ - case c == '=' || c == '(' || c == ')' || c == '{' || c == '}': - out = append(out, searchToken{kind: c}) - i++ - case c == '"': - text, next, ok := lexQuoted(src, i) - if !ok { - return nil, false - } - - out = append(out, searchToken{kind: sQuoted, text: text}) - i = next - default: - j := i - for j < len(src) && !strings.ContainsRune(" \t\n,=(){}\"", rune(src[j])) { - j++ - } - - word := src[i:j] - if strings.HasPrefix(word, ":") { - // :aws.AccountId and other designators are not supported. - return nil, false - } + tok, next, ok := nextSearchToken(src, i) + if !ok { + return nil, false + } - out = append(out, searchToken{kind: sWord, text: word}) - i = j + if tok.kind != 0 { + out = append(out, tok) } + + i = next } return out, true } +// nextSearchToken reads the token at i. A separator gives a zero token. +func nextSearchToken(src string, i int) (tok searchToken, next int, ok bool) { + switch c := src[i]; c { + case ' ', '\t', '\n', ',': + return searchToken{}, i + 1, true + case sEquals, sOpen, sClose, sLBrace, sRBrace: + return searchToken{kind: c}, i + 1, true + case '"': + text, end, quoted := lexQuoted(src, i) + + return searchToken{kind: sQuoted, text: text}, end, quoted + } + + j := i + for j < len(src) && !strings.ContainsRune(searchDelimiters, rune(src[j])) { + j++ + } + + word := src[i:j] + + // :aws.AccountId and other designators are not supported. + return searchToken{kind: sWord, text: word}, j, !strings.HasPrefix(word, ":") +} + // lexQuoted reads a double-quoted string. A backslash escapes the next // character. func lexQuoted(src string, start int) (text string, next int, ok bool) { @@ -472,7 +478,7 @@ func (p *searchParser) parseOr() (searchTerm, bool) { return nil, false } - for p.peekWord("OR") { + for p.peekWord(kwOr) { p.pos++ right, ok := p.parseAnd() @@ -493,8 +499,8 @@ func (p *searchParser) parseAnd() (searchTerm, bool) { return nil, false } - for !p.done() && !p.peek(sClose) && !p.peekWord("OR") { - if p.peekWord("AND") { + for !p.done() && !p.peek(sClose) && !p.peekWord(kwOr) { + if p.peekWord(kwAnd) { p.pos++ } @@ -510,7 +516,7 @@ func (p *searchParser) parseAnd() (searchTerm, bool) { } func (p *searchParser) parseUnary() (searchTerm, bool) { - if p.peekWord("NOT") { + if p.peekWord(kwNot) { p.pos++ inner, ok := p.parseUnary() @@ -547,7 +553,7 @@ func (p *searchParser) parsePrimary() (searchTerm, bool) { p.pos++ return valueTerm{value: tok.text, exact: true}, true case sWord: - if tok.text == "AND" || tok.text == "OR" { + if tok.text == kwAnd || tok.text == kwOr { return nil, false }