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. 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/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..f6b8dab20 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -255,6 +255,33 @@ 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 +332,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/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/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/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 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..e7f411fd8 100644 --- a/services/monitoring/metricmath/eval.go +++ b/services/monitoring/metricmath/eval.go @@ -1,65 +1,131 @@ 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. -func (r result) asSeries() Series { - if !r.isScalar { +// 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 { + 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 { + r := f(result{series: l.Series}) + out = append(out, Labeled{Label: l.Label, Series: r.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 +134,53 @@ 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 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()] { + continue + } + + seen[ts.UnixNano()] = true + + out = append(out, ts) + } } - return result{series: out} + 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 +202,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 opEq: + return truth(a == b) + case opNe: + return truth(a != b) + case opLt: + return truth(a < b) + case opLe: + return truth(a <= b) + case opGt: + return truth(a > b) + case opGe: + 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 +256,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) +} - return result{series: series}, nil +// 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 } + +func (keywordNode) evaluate(scope) (result, error) { + return result{series: emptySeries()}, nil } type negNode struct{ operand node } @@ -130,7 +282,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 +291,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 combine(n.op, left, right), nil + 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 + } + + 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 +360,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 +394,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..c033d73e5 --- /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, 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, 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..ceef47b6d 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,63 @@ 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) { + 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 one := expr[start : start+1]; one { + case opLt, opGt: + return mathToken{kind: tokCompare, ident: one}, 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 +153,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 +165,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 kwAnd: + return mathToken{kind: tokAnd}, i + case kwOr: + 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 +205,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 +368,205 @@ 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" + kwAnd = "AND" + kwOr = "OR" + kwNot = "NOT" + 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: + return buildIf(args) + case fnFill: + return buildFill(args) + case fnSearch: + return buildSearch(args) default: return nil, errMathParse } } +// 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, isTerm := args[0].(stringNode) + stat, isStat := args[1].(stringNode) + + if !isTerm || !isStat || 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 { + period, ok := searchPeriod(args[2]) + if !ok { + return nil, errMathParse + } + + 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 { + 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 +583,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 +600,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..e225b4bd8 --- /dev/null +++ b/services/monitoring/metricmath/search.go @@ -0,0 +1,592 @@ +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, schemaOK := p.parseSchema() + if !schemaOK { + 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 +} + +// 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); { + tok, next, ok := nextSearchToken(src, i) + if !ok { + return nil, false + } + + 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) { + 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(kwOr) { + 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(kwOr) { + if p.peekWord(kwAnd) { + 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(kwNot) { + 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 == kwAnd || tok.text == kwOr { + 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 + } +}