Skip to content

fix(aws-cloudwatch): DashboardNotFoundError shape, FILL/IF/SEARCH metric math, non-goals - #1537

Open
Satyam-Trivedi-ZS wants to merge 8 commits into
stackshy:developmentfrom
Satyam-Trivedi-ZS:fix/aws-cloudwatch-parity-1520
Open

Satyam-Trivedi-ZS wants to merge 8 commits into
stackshy:developmentfrom
Satyam-Trivedi-ZS:fix/aws-cloudwatch-parity-1520

Conversation

@Satyam-Trivedi-ZS

@Satyam-Trivedi-ZS Satyam-Trivedi-ZS commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Part of #1520 (AWS CloudWatch real-cloud parity gaps).

  • Missing dashboards now map to types.DashboardNotFoundError in the SDK (rpc-v2-cbor and JSON).
  • Metric math now evaluates FILL, IF, SEARCH, and the comparison and logical operators. Before, these returned no data.
  • Review follow-up:
    • FILL(SEARCH(...)) now fills on the period the SEARCH reads at (M1).
    • Arithmetic now follows the guide's rule: a missing point counts as 0, and a division by zero drops the point (L1).
    • A SEARCH period argument must be a valid metric period, and the FILL grid can no longer overflow. Before, an out-of-range SEARCH period hung or panicked the request (H1).
    • Arithmetic over a SEARCH keeps the SEARCH period for FILL (L7).
  • GetMetricWidgetImage and the newer AnomalyDetectorId surface are recorded as non-goals.

Per-item status

Item Status Evidence
GetDashboard/DeleteDashboards error code FIXED (partly a real gap) The issue says the real code is DashboardNotFoundError. That is the shape name. Its wire code is ResourceNotFound with HTTP 404 (botocore cloudwatch/2010-08-01/service-2.json; the GetDashboard API reference lists ResourceNotFound, 404). The query/XML code was already correct. The real gap was in rpc-v2-cbor and JSON: they put __type: ResourceNotFound, but aws-sdk-go-v2 cloudwatch v1.56.2 matches the shape name, so errors.As never found types.DashboardNotFoundError.
Metric math FILL / IF / SEARCH / ANOMALY_DETECTION_BAND FIXED for FILL, IF and SEARCH. ALREADY-FIXED for ANOMALY_DETECTION_BAND ANOMALY_DETECTION_BAND(m1[,k]) already returned the lower and upper rows (metricDataRows, TestGetMetricDataAnomalyBand). FILL, IF and SEARCH failed to parse, so they returned no data.
AnomalyDetectorId response + AnomalyDetectorIds filter; band row labels UNVERIFIABLE (not changed, documented) The API reference documents these members (PutAnomalyDetector, DescribeAnomalyDetectors, DeleteAnomalyDetector). The pinned aws-sdk-go-v2 cloudwatch v1.56.2 and botocore 1.42.97 do not have them, and the ID format is not documented. The docs also do not give the band row labels. Recorded in docs/coverage/nongoals/monitoring.md.
GetMetricWidgetImage SKIPPED (documented non-goal) It returns a rendered PNG graph, which is presentation output, and cloudemu has no renderer. A fake image would mislead callers.

Changes

Dashboard errors (server/aws/cloudwatch, wire translation only):

  • wireError gets an optional shape. dashboardErr turns a store NotFound into code ResourceNotFound, shape DashboardNotFoundError, status 404.
  • The CBOR and JSON writers put the shape in __type and ResourceNotFound;Sender in X-Amzn-Query-Error. ErrorCode() therefore stays ResourceNotFound, and the CLI still shows ResourceNotFound.
  • The query/XML path is unchanged.

Metric math (services/monitoring/metricmath). Only providers/aws/cloudwatch and server/aws/cloudwatch import this package. Semantics come from Use metric math and the search expression syntax / examples:

  • Comparison operators == != < <= > >= and logical operators AND/&&, OR/||. When a timestamp exists in only one series, the other series counts as 0 there.
  • Arithmetic between two series uses the same rule. "Missing values in a time series are treated as 0. If the value of a data point causes a function to attempt to divide by zero, the data point is dropped." Before, arithmetic dropped any timestamp that only one series had, and x/0 returned 0.
  • IF(cond, a[, b]) follows the documented per-point rules; all three IF tables from the guide are unit-tested verbatim.
  • FILL(x, S | TS | REPEAT | LINEAR) fills the periods of the query range [StartTime, EndTime). The step is the period of its input: the period a SEARCH reads at (its argument, else the entry Period, else the 60s a period-less read uses), and otherwise the expression period.
  • SEARCH(' {Namespace,Dim...} term ', 'Stat'[, period]) covers:
    • the schema (exact dimension-name set), partial and composite tokens, exact "..." matching, Name=value designators, and AND/OR/NOT with parentheses
    • search scope: only metrics with data in the past two weeks
  • The SEARCH period argument follows the MetricStat Period rule: 1, 5, 10, 20, 30 or a multiple of 60, up to the Integer maximum. The rule is metricmath.ValidPeriod, which the provider's ValidatePeriod (fix(aws-cloudwatch): validate metric Period on GetMetricData and GetMetricStatistics #1703) also uses. GetMetricData rejects an invalid SEARCH period with ValidationError: Error in expression '<id>': Invalid SEARCH period. ....
  • The FILL grid counts in whole seconds, so it ends for any period, whatever its source.
  • Each SEARCH match becomes its own GetMetricData result row with the query's Id.
  • The provider's math alarms and anomaly-detector state also pass the time range, so FILL works in alarms.

Docs: new docs/coverage/nongoals/monitoring.md, plus the regenerated docs/coverage. No driver interface changed.

Provider Coverage

  • AWS
  • Azure: n/a (CloudWatch metric math and dashboards are AWS-only)
  • GCP: n/a
  • OCI: n/a

Checklist

  • All tests pass (go test ./...)
  • Linter: no new issues on the touched packages (full golangci-lint run, compared with development)
  • Every provider the change applies to implements the same behavior
  • Integration tests added to cloudemu_test.go (covered by aws-sdk-go-v2 wire tests in server/aws/cloudwatch)
  • Unit tests added to provider/service test files
  • Regenerated docs (go generate ./...) and committed the result

Test Plan

Each new test fails on the code before its fix and passes after:

  • Dashboard errors
    • TestSDKDashboardNotFoundShape (aws-sdk-go-v2): errors.As finds types.DashboardNotFoundError, ErrorCode()=="ResourceNotFound", 404.
    • TestJSONProtocolErrors expects __type: DashboardNotFoundError with X-Amzn-Query-Error: ResourceNotFound;Sender.
  • FILL / IF / SEARCH
    • TestSDKGetMetricDataFILLAndIF and TestSDKGetMetricDataSEARCH (aws-sdk-go-v2).
    • Unit tests in services/monitoring/metricmath/functions_test.go: TestIFExpressions, TestComparisonAndLogicalOperators, TestFILL*, TestSEARCH, TestSEARCHReadsEachMetric, TestFILLOverSEARCH, TestFunctionReferences.
  • M1
    • TestSDKGetMetricDataFILLOverSEARCHNoPeriod (wire): FILL(SEARCH(..., 300), 0) and FILL(SEARCH(...), 0) with no Period on the query.
    • TestFILLOverSEARCHUsesSearchPeriod (unit).
    • Before the fix both returned the single raw point.
  • L1
    • TestSDKGetMetricDataArithmeticMissingAsZero (wire) and TestArithmeticMissingAsZero (unit), using the guide's metric1 [30,20,0,0] and metric2 [20,-,20,-].
    • m1 - m2 gives [10,20,-20,0] (before: [10,-20]), and m1 / m2 gives [1.5, 0].
    • TestSeriesAlignByTimestamp and the divide-by-zero case in TestEvaluateExpressions now expect the documented results.
    • The wire test now also asserts that every expected row comes back (L8).
  • H1 (each test guards against a hang with a timeout)
    • TestFILLOverSEARCHHugePeriod (unit): FILL(SEARCH('{Rev/App,InstanceId}','Sum',9223372037),0), a 2^55 period and 2^63. Before the fix: an endless loop, or a divide-by-zero panic.
    • TestFILLHugeEntryPeriod (unit): FILL with an entry Period of 9223372037, 2^55, 2^62 or MaxInt64. Before the fix: a hang or a panic.
    • TestInvalidSearchPeriod, TestValidPeriod (unit).
    • TestGetMetricDataSearchPeriodValidation (query protocol, 10s client timeout) and TestSDKGetMetricDataSearchPeriodValidation (aws-sdk-go-v2): 400 ValidationError. Before the fix, the query request hung until the client timeout.
  • L7: TestFILLOverSEARCHArithmeticKeepsPeriod (unit) and the arithmetic over SEARCH case of TestSDKGetMetricDataFILLOverSEARCHNoPeriod.

Checks:

  • go build ./...
  • go vet on the touched packages
  • go test ./... (all 660 packages) and go test -race on services/monitoring/..., server/aws/cloudwatch, providers/aws/cloudwatch and persist
  • go generate ./... leaves no diff
  • Full golangci-lint run (no --new-from-rev) on services/monitoring/..., server/aws/cloudwatch/... and providers/aws/cloudwatch/...: 10 issues on the branch versus 11 on development, all pre-existing. The branch removes one dupl and adds none.

Real-user test: cloudemu serve -aws-port 4610 ... in the background, driven with AWS CLI 1.44.87 / botocore 1.42.97 at --endpoint-url http://127.0.0.1:4610 --region us-east-1:

$ aws cloudwatch get-dashboard --dashboard-name missing
An error occurred (ResourceNotFound) when calling the GetDashboard operation ...       (exit 255)
$ aws cloudwatch delete-dashboards --dashboard-names keep missing                       -> ResourceNotFound, "keep" survives

# M1: FILL over SEARCH, no Period on the query, range 20 min, one point per instance
$ aws cloudwatch put-metric-data --namespace RevApp --metric-data "MetricName=CPU,Value=40,Timestamp=T+5m,Dimensions=[{Name=InstanceId,Value=i-1}]" "MetricName=CPU,Value=70,Timestamp=T+10m,Dimensions=[{Name=InstanceId,Value=i-2}]"
$ aws cloudwatch get-metric-data --start-time T --end-time T+20m --scan-by TimestampAscending --metric-data-queries file://q.json
f300  i-1 CPU  4   0,40,0,0          # FILL(SEARCH('{RevApp,InstanceId} MetricName="CPU"', 'Average', 300), 0)
f300  i-2 CPU  4   0,0,70,0
f60   i-1 CPU  20  0,0,0,0,0,40,0,...  # FILL(SEARCH('{RevApp,InstanceId} MetricName="CPU"', 'Average'), 0)
f60   i-2 CPU  20  0,...,70,...,0

# L1: the guide's metric1 [30,20,0,0] and metric2 [20,-,20,-]
diff   10,20,-20,0    # m1 - m2
ratio  1.5,0          # m1 / m2: the two points that divide by zero are dropped

# H1: out-of-range SEARCH period returns at once; a valid one still fills
$ aws cloudwatch get-metric-data ... --metric-data-queries file://bad.json   # FILL(SEARCH('{Rev/App,InstanceId}','Sum',9223372037),0)
An error occurred (ValidationError) when calling the GetMetricData operation: Error in expression 'e1': Invalid SEARCH period. Valid periods are 1, 5, 10, 20, 30, 60 and any multiple of 60.   (exit 255, under 1s)
# SEARCH(..., 36028797018963968): same ValidationError, no dropped connection
e1  i-1 Revenue  0,0,4,0     # FILL(SEARCH('{Rev/App,InstanceId}','Sum',300),0)
e2  i-1 Revenue  0,0,8,0     # FILL(SEARCH('{Rev/App,InstanceId}','Sum',300)*2,0), no query Period (L7)

# Earlier run: FILL(m1,0) -> 0,10,0,30,0; FILL REPEAT -> 10,10,30,30; FILL LINEAR -> 10,20,30;
# IF(m1 > 15,1,0) -> 0,1; SEARCH -> one row per host

Follow-ups (from review, not in this PR)

  • L2. Reject SEARCH in PutMetricAlarm. The docs say an alarm cannot be based on SEARCH, but they do not give the error code or message, so I'd rather not invent one.
  • L3. Typed SDK errors other than the dashboard one over rpc-v2-cbor. This changes the raw __type of every CBOR error that existing parity tests assert, and ValidationError has no model shape, so it deserves its own PR. Tracked in aws-cloudwatch: map typed SDK errors over rpc-v2-cbor (writeCBORError) #1716.
  • L4. DeleteDashboards partial deletes. The current API reference says it deletes as many as it can, while the pinned SDK model's documentation says no dashboards are deleted on error. I'll recheck once the SDK is bumped.
  • L5. SEARCH limits (1024-character query, 500 series per graph). The docs give the limits but not the error that enforces them.

Related Issues

Part of #1520

@NitinKumar004

Copy link
Copy Markdown
Collaborator
Screenshot 2026-10-11 at 12 48 22 PM remove claude attribution please

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The dashboard error fix is right, and the IF and comparison/logical semantics match the user guide tables exactly. I checked everything on the pushed head (75b7f24) against the AWS CLI (query/XML), raw awsJson1_0, and aws-sdk-go-v2 (rpc-v2-cbor), with Terraform at the end. There's one Medium in the new FILL code. Everything else is Low, and most of the Lows were already there before this PR.

Issue coverage (#1520)

Item Status Evidence
GetDashboard / DeleteDashboards not-found error Fixed CBOR via aws-sdk-go-v2: errors.As(err, *types.DashboardNotFoundError) is true, ErrorCode() is ResourceNotFound, status 404, on both ops. JSON: __type/X-Amzn-Errortype is DashboardNotFoundError, X-Amzn-Query-Error: ResourceNotFound;Sender, 404. CLI (query): ResourceNotFound. TestSDKDashboardNotFoundShape and TestJSONProtocolErrors fail on the merge base and pass here. Your reading is correct: the botocore model gives the shape code ResourceNotFound, status 404.
Metric math FILL / IF / SEARCH Fixed, with one gap (M1) Live GetMetricData over all three protocols. The comparison table, the AND table and all three IF tables from the guide reproduce exactly. FILL with S, TS, REPEAT and LINEAR fills the range, in ascending and descending order. SEARCH matches the schema, designators, partial/composite/exact tokens and NOT, and returns one row per match with the query Id. TestSDKGetMetricDataFILLAndIF and TestSDKGetMetricDataSEARCH fail on base and pass here.
ANOMALY_DETECTION_BAND Already fixed Band rows already came back before this PR. Nothing changed here apart from asSeries() plumbing.
AnomalyDetectorId / AnomalyDetectorIds Not done, documented The pinned SDK and botocore don't model these fields. Writing it up as a non-goal is reasonable.
GetMetricWidgetImage Skipped, documented non-goal Reasonable. The call still gets UnknownOperation.

Findings

Medium

M1. FILL does nothing when the expression has no period it can resolve, which is the common FILL(SEARCH(...)) case. services/monitoring/metricmath/metricmath.go:175-178, functions.go:150, search.go:68-71

The FILL grid comes from Period(e.queries, q). That function only looks at the query's own Period and at MetricStat entries. It never sees the period argument of a SEARCH. When neither gives a value, gridPeriod is 0, grid() returns nil, and FILL hands its input back unchanged. The SEARCH itself still reads at 60s, because the provider defaults period 0 to 60. So the points come back on a 60s (or 300s) step but nothing gets filled. Reproduced live against serve, with no Period on the MetricDataQuery:

  • FILL(SEARCH('{Rev/App,InstanceId}', 'Average', 300), 0) returns 1 point per series. With Period: 300 on the query it returns all 8 filled points.
  • FILL(SEARCH('{Rev/App,InstanceId}', 'Average'), 0) also returns 1 point per series.

A user who graphs or queries FILL(SEARCH('{AWS/EC2,InstanceId} CPUUtilization','Average',300), 0) gets the sparse series back with no error. Fix: let a SEARCH node report its period (the explicit argument, else the 60s default it actually reads at), and use that in the grid when Period() returns 0. Then the fill step always equals the step of the series being filled. A wire test for FILL(SEARCH(..., 300), 0) with no query Period would cover it.

Low

L1. Arithmetic between two series still drops timestamps that only one series has, and x/0 returns 0. services/monitoring/metricmath/eval.go:95, applyOp
The page this PR cites says, under Basic arithmetic functions: "Missing values in a time series are treated as 0. If the value of a data point causes a function to attempt to divide by zero, the data point is dropped." (Use metric math). Live, with the guide's own series m1=[30,20,0,0] and m2=[20,-,20,-], m1 - m2 returns only 2 points, [10, -20]. The doc rule gives [10, 20, -20, 0]. This behaviour predates the PR, but the PR rewrote this exact code into pointwise(..., union), so arithmetic could switch to union=true here (and drop divide-by-zero points) at little cost. If you'd rather keep it out of scope, the non-goals note should say so.

L2. PutMetricAlarm accepts a SEARCH expression. server/aws/cloudwatch/core_alarms.go (expressionRefsKnown, not in this diff)
MetricDataQuery says "Any expression used in a PutMetricAlarm operation must return a single time series", and the guide says "You can't create an alarm based on a SEARCH expression" (MetricDataQuery). Live, PutMetricAlarm with Expression: SEARCH('{Rev/App,InstanceId}','Average',60) succeeds, and because the alarm evaluator has no lister the alarm just sits in INSUFFICIENT_DATA. The parser now recognises SEARCH, so the alarm validation could reject it with a ValidationError.

L3. Typed SDK errors other than the dashboard one still don't map over rpc-v2-cbor. server/aws/cloudwatch/handler.go:252-258 (writeCBORError)
This is the root cause the PR found, fixed for one error. The CBOR path writes the query code (InvalidParameterValue, MissingParameter, ...) straight into __type with no X-Amzn-Query-Error, while the SDK switches on InvalidParameterValueException, MissingRequiredParameterException and so on. errors.As(err, *types.InvalidParameterValueException) is therefore always false over CBOR. JSON already maps these through jsonErrorShapes. Pointing the CBOR writer at the same table plus the header would fix every error at once. This is fine as a follow-up.

L4. DeleteDashboards is all-or-nothing. providers/aws/cloudwatch/dashboards.go:95-106 (not in this diff)
The DeleteDashboards reference says "If there is an error during this call, the operation attempts to delete as many dashboards as possible." Here, one missing name means none of the others get deleted. This predates the PR. The current reference page also no longer lists a not-found error for DeleteDashboards, although the pinned SDK model still does, so it's worth a recheck when the SDK is bumped.

L5. SEARCH limits aren't enforced. services/monitoring/metricmath/search.go
The guide gives a 1024-character maximum query size and 500 time series per graph (search expression syntax). Neither limit is checked, so a broad SEARCH returns every match.

L6. Decorative separator comment. services/monitoring/metricmath/search.go:316 // ---- search string parser ----. A plain comment, or moving the parser to its own file, would match the rest of the repo.

AWS docs check

  • FILL: S, TS, REPEAT and LINEAR all follow the function table. What REPEAT and LINEAR do at the edges isn't in the docs, and the non-goals note records the choice made here. OK.
  • IF: all three tables match, including the case where IF(m1, scalar2, metric3) drops a false point when metric3 has no value. OK.
  • Comparison/logical: missing-as-0 union matches the table. OK. Arithmetic does not; see L1.
  • SEARCH: schema exact dimension set, tokenization examples (CustomCount1, SDBFailure, couNT, count1, network/errors), exact match with "...", designators, NOT, and the two-week scope all match the syntax page. The labels and the limits aren't covered (L5).
  • Dashboard errors: the model's DashboardNotFoundError has code ResourceNotFound, status 404, and the PR emits that on all three protocols.

Terraform (hashicorp/aws 6.66.0, endpoints { cloudwatch = ... } against serve)

aws_cloudwatch_dashboard (body with a FILL(SEARCH(...)) widget) and aws_cloudwatch_metric_alarm with metric_query (IF(m1 > 5, FILL(m1, REPEAT), 0) plus an m1 MetricStat):
apply completed with no waiter hang, then plan -detailed-exitcode returned 0. I then updated the threshold and the dashboard body, and plan returned 0 again. Destroy completed, after which get-dashboard returns ResourceNotFound 404 and describe-alarms returns nothing.
A separate math alarm FILL(m1, 100) > 50 over a gap went to ALARM, so FILL also works inside alarms.

CLI / protocol e2e

  • aws cloudwatch get-dashboard / delete-dashboards on a missing name: ResourceNotFound, exit 254.
  • get-metric-data (query) with SEARCH (Label prefix applied per row), FILL REPEAT and IF: correct. The same SEARCH over raw awsJson1_0 and over the Go SDK (CBOR) gives the same rows.

What I verified

  • Build, vet and gofmt pass on the touched packages. go mod tidy leaves no diff, and go generate leaves no diff.
  • go test -race passes on services/monitoring/metricmath, server/aws/cloudwatch and providers/aws/cloudwatch. Coverage is 92.3%, 87.7% and 93.7% (base: 93.9% and 87.5% for the first two). The wire package was already under 90% before this PR.
  • A throwaway -race probe passed: 8 goroutines running PutMetricData, FILL(SEARCH(...), REPEAT) GetMetricData, and Put/Delete/GetDashboard at the same time.
  • golangci-lint (v2.14.0) on the touched packages: the PR removes one dupl and adds two prealloc hints in search.go. CI Lint passes.
  • The new wire tests fail on the merge base and pass on the head.
  • Layering: the evaluator change sits in services/monitoring/metricmath (AWS-only consumers) and the wire change only translates errors. No driver interface changed, so the mirror rule and persistence don't apply. No new operations, so the auth declarations are unchanged.
  • CI is green.

Verdict

Changes requested for M1: FILL should use the same period the SEARCH reads at. Once that's fixed with a test, this is good to merge. L1 to L6 can be follow-ups.

// entries keep their own periods, as far apart as the points it reads.
grid := period
if grid == 0 {
grid = Period(e.queries, q)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When neither the query nor any MetricStat has a period, Period() returns 0 here. It never sees a SEARCH's own period argument, so the FILL grid ends up nil and FILL does nothing. Live: FILL(SEARCH('{Rev/App,InstanceId}', 'Average', 300), 0) with no Period on the query returns 1 point per series. Add Period: 300 and it returns all 8 filled points. The SEARCH still reads at 60s (the provider's default for period 0), so the series has a step but nothing fills it. Could the SEARCH node report its period (explicit arg, else the 60s it actually reads at) and the grid use that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 879091c. A SEARCH now reports the period it reads at (its period argument, else the entry Period, else the 60s a period-less read uses), and FILL builds its grid from that step. With no Period on the query, FILL(SEARCH(..., 300), 0) now returns every 300s period, and FILL(SEARCH(...), 0) returns every 60s period. Covered by TestSDKGetMetricDataFILLOverSEARCHNoPeriod (wire) and TestFILLOverSEARCHUsesSearchPeriod (unit). Both fail on the previous head.


period := n.period
if period == 0 {
period = s.gridPeriod

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When gridPeriod is 0 this passes period 0 to fetch, which the provider turns into 60. FILL, though, sees 0 and skips the grid (see the comment on metricmath.go). Resolving one effective period here and using it for both the fetch and the FILL grid would keep the two consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 879091c. The SEARCH resolves one effective period (effectivePeriod), uses it for the fetch, and returns it with the result. FILL uses the same value, so the read step and the fill step always match.

Comment thread services/monitoring/metricmath/eval.go Outdated
case union:
return result{series: unionSeries(f, left.series, right.series)}
default:
return result{series: intersectSeries(f, left.series, right.series)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arithmetic still intersects. The page you cite says for arithmetic: "Missing values in a time series are treated as 0. If the value of a data point causes a function to attempt to divide by zero, the data point is dropped." With the guide's m1=[30,20,0,0], m2=[20,-,20,-], m1 - m2 returns [10, -20] here; the doc rule gives [10, 20, -20, 0]. applyOp also returns 0 for x/0 where the doc drops the point. This predates the PR, but with pointwise(..., union) in place it looks like a small change. If it's out of scope, the non-goals note could mention it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ad761b7, following the arithmetic rule on that page. Arithmetic between two series now runs over the union of their timestamps with a missing point read as 0, and a point that would divide by zero is dropped (this also applies to TS/S and S/S). With the guide series m1=[30,20,0,0] and m2=[20,-,20,-], m1 - m2 is now [10,20,-20,0] and m1 / m2 is [1.5, 0]. Covered by TestSDKGetMetricDataArithmeticMissingAsZero (wire) and TestArithmeticMissingAsZero (unit). TestSeriesAlignByTimestamp and the old m1/0 case now expect the documented results.

return
}

w.Header().Set(queryErrorHeader, code+";"+faultOf(status))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header and the shape mapping now only happen for the dashboard error. The generic writeCBORError above still puts the query code (InvalidParameterValue, MissingParameter, ...) in __type with no X-Amzn-Query-Error, so over rpc-v2-cbor errors.As(err, *types.InvalidParameterValueException) never matches (the SDK switches on the ...Exception shape names). Reusing jsonErrorShapes and this header in writeCBORError would fix that whole class. Fine as a follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the root cause. I tried pointing writeCBORError at jsonErrorShapes plus the header. It changes the raw CBOR __type that the existing parity tests assert for about a dozen unrelated operations (PutMetricData, PutMetricAlarm, metric streams, dashboards). It also needs care for ValidationError, which has no shape in the model (the table maps it to ValidationException, so the SDK would still fall back to a generic error). I left it for a dedicated follow-up PR that covers every operation, rather than widening this one.

}
}

// ---- search string parser ----

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: a plain comment would match the rest of the repo better than the // ---- separator. Moving the parser into its own file would also work.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 879091c: it is a plain comment now (// The search string parser.).

@NitinKumar004

Copy link
Copy Markdown
Collaborator

One project rule to fix before merge: please remove the AI attribution from this PR.

  • PR description: delete the "Generated with Claude Code" line at the end.
  • Commits: the commit messages carry a Co-Authored-By: Claude ... trailer. Please reword them without it (for example git rebase -i origin/development, reword each commit, delete the trailer, then git push --force-with-lease). Squashing into one clean commit is also fine.

The repo doesn't accept AI attribution lines in commits or PR descriptions. Thanks!

@Satyam-Trivedi-ZS
Satyam-Trivedi-ZS force-pushed the fix/aws-cloudwatch-parity-1520 branch from 75b7f24 to ad761b7 Compare October 12, 2026 18:54
@Satyam-Trivedi-ZS

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review, @NitinKumar004. Here is where each item landed (head is now ad761b78):

M1, FILL over SEARCH with no resolvable period: fixed in 879091cd.
A SEARCH now reports the period it reads at: its argument, else the entry Period, else the 60s a period-less read uses. FILL builds its grid from that step. Tests: TestSDKGetMetricDataFILLOverSEARCHNoPeriod (wire, with and without the SEARCH period argument, no Period on the query) and TestFILLOverSEARCHUsesSearchPeriod (unit). Live with the CLI against serve, over a 20-minute range, FILL(SEARCH(...,300),0) now returns 4 points per instance and the version without the 300 returns 20.

L1, arithmetic missing values and x/0: fixed in ad761b78.
This follows the arithmetic rule in "Use metric math". Arithmetic now treats a timestamp that only one series has as 0 in the other, and drops a point that would divide by zero. With the guide's m1/m2, m1 - m2 gives [10,20,-20,0] and m1 / m2 gives [1.5, 0]. Tests: TestSDKGetMetricDataArithmeticMissingAsZero (wire) and TestArithmeticMissingAsZero (unit). TestSeriesAlignByTimestamp and the old m1/0 case now expect the documented results.

L2, PutMetricAlarm accepts SEARCH: left as a follow-up.
The docs say an alarm can't be based on SEARCH, but they don't give the error code or message for that rejection. I'd rather not invent one.

L3, typed CBOR errors beyond the dashboard one: left as a follow-up.
I tried routing writeCBORError through jsonErrorShapes plus X-Amzn-Query-Error. It changes the raw __type that the existing parity tests assert for about a dozen other operations, and ValidationError has no model shape. That deserves its own PR covering every operation.

L4, DeleteDashboards all-or-nothing: left as a follow-up.
The current API reference says it deletes as many as it can. The pinned SDK/botocore model's documentation still says "If there is an error during this call, no dashboards are deleted" and lists the not-found error. I'll recheck once the SDK is bumped, as you suggested.

L5, SEARCH limits: left as a follow-up.
The guide gives the 1024-character and 500-series limits, but not the error (or truncation) behaviour that enforces them.

L6, separator comment: fixed in 879091cd. It's a plain comment now.

Attribution: removed. None of the branch's commit messages carries the trailer any more (rewritten and force-pushed), and the description no longer has the footer.

Checks on the new head:

  • go build ./...
  • go vet on the touched packages
  • go test -p 2 ./...: all packages pass
  • golangci-lint run --new-from-rev=upstream/development on the touched packages: 0 issues
  • Each new test fails on the previous head and passes now.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up commits. M1 and L1 are fixed and the separator comment is gone. While I was testing the new SEARCH period path, though, I found that an out-of-range SEARCH period can hang the server or crash the request. That needs a fix before merge. Details below.

What I checked

  • The new commits 879091c and ad761b7 against each point from the 2026-10-12 review.
  • The branch still merges cleanly onto the current development (46 commits ahead, and none of them touch cloudwatch or metricmath). Build, go mod tidy and go generate on the merge result leave no diff. Scoped tests pass on the merge result for services/monitoring/..., server/aws/cloudwatch, providers/aws/cloudwatch, providers/oci/monitoring and persist.
  • On the head: build, vet and gofmt are clean. go test -race passes on metricmath (92.4%), server/aws/cloudwatch (87.7%) and providers/aws/cloudwatch (93.7%). I ran golangci-lint 2.14.0 on the touched packages with no --new-from-rev and compared it with a merge-base worktree. The PR removes one dupl and adds two prealloc hints in search.go, which is unchanged since last time. gocyclo is the same on both sides. CI is green.
  • Live checks against cloudemu serve from the head with the AWS CLI, raw awsJson1_0 and Terraform hashicorp/aws 6.66.0 (apply, plan 0, update, plan 0, destroy).
  • There are no AI attribution lines in the body or the commits.

Earlier findings

Point Status Evidence
M1 FILL(SEARCH) with no Period Fixed Live, with no Period on the query: FILL(SEARCH('{Rev/App,InstanceId}','Sum',300),0) returns 4 filled points per series over 20 minutes, and FILL(SEARCH(...,'Sum'),0) returns 20. New tests TestSDKGetMetricDataFILLOverSEARCHNoPeriod and TestFILLOverSEARCHUsesSearchPeriod cover it. There is a narrower leftover case; see Low L7.
L1 arithmetic missing-as-0 and divide by zero Fixed Live with the guide's series m1=[30,20,0,0] and m2=[20,-,20,-]: m1-m2 gives [10,20,-20,0], m1/m2 gives [1.5,0], m1/0 is empty and m1>m2 gives [1,1,0,0]. That matches the "Basic arithmetic functions" text on the Use metric math page.
L2 PutMetricAlarm accepts SEARCH Open put-metric-alarm with SEARCH('{Rev/App,InstanceId}','Average',60) still succeeds.
L3 typed errors over rpc-v2-cbor Open, deferred Your reasoning for a separate PR covering every operation makes sense. Please open a ticket so it doesn't get lost.
L4 DeleteDashboards all-or-nothing Open delete-dashboards a nope returns ResourceNotFound and a is still listed. This was already the case before the PR.
L5 SEARCH limits (1024 chars, 500 series) Open Unchanged.
L6 separator comment Fixed It now reads // The search string parser.

High

H1. An out-of-range SEARCH period hangs the server goroutine or panics the request. services/monitoring/metricmath/parser.go:550-556 (searchPeriod), functions.go:200-215 (grid), search.go:64-75 (effectivePeriod)

searchPeriod accepts any positive whole number. Since 879091c that value also sets the FILL step. grid then computes time.Duration(period) * time.Second, and that overflows for any period above about 9.2e9 seconds:

  • FILL(SEARCH('{Rev/App,InstanceId}','Sum',9223372037),0) wraps the step to a negative value. The e.end.Sub(e.start)/step > maxFillPoints check passes because the quotient is negative, and for ts := e.start; ts.Before(e.end); ts = ts.Add(step) never ends. Live against serve, the CLI timed out, but the handler kept running at 30-130% CPU with RSS climbing to 1.7 GB. On SIGTERM the server could not shut down cleanly (context deadline exceeded). Nothing checks the request context, so one request is enough to pin a core and eventually exhaust memory.
  • With a period of 2^55 (36028797018963968), the step wraps to exactly 0, and grid panics with an integer divide by zero at functions.go:207. Without FILL, the same SEARCH panics in the provider at providers/aws/cloudwatch/cloudwatch.go:278 (buildMetricResult, where periodDur is 0). The connection drops and the CLI reports "Connection was closed".
  • The integer check num.val != float64(int(num.val)) also behaves differently on each platform once the value is past the int64 range. On arm64, int(2^63) saturates to MaxInt64, the round trip compares equal, and 2^63 is accepted. On amd64 it becomes MinInt64 and is rejected.

The same overflow is reachable through the entry's own Period (FILL(m1,0) with Period: 36028797018963968 also drops the connection), since GetMetricData never validates MetricDataQuery.Period or MetricStat.Period. That validation gap was there before this PR, but FILL and SEARCH are the new code that loops on the bad value.

Suggested fix:

  • In searchPeriod, bound the float before converting it, for example num.val > maxPeriod returns false, where the cap is a value AWS can return such as the 455-day retention or a day multiple. Also require 1, 5, 10, 20, 30, or a multiple of 60, matching the documented MetricStat periods. Return a parse error otherwise.
  • Make grid safe whatever its caller passes. Count periods in seconds first (int64(e.end.Sub(e.start)/time.Second) / int64(period) > maxFillPoints) and reject step <= 0.
  • Ideally, also validate Period on GetMetricData queries at the wire and return a ValidationError, as validPeriod already does for alarms.

A regression test for FILL(SEARCH(..., 9223372037), 0) and for a 2^55 period would cover this.

Medium

None.

Low

L7. FILL loses the SEARCH period once the SEARCH goes through arithmetic. services/monitoring/metricmath/eval.go:61-70 (mapArray)
mapArray returns result{isArray: true, array: out} without the input's period. As a result, FILL(SEARCH('{Rev/App,InstanceId}','Sum',300)*2, 0) with no query Period returns one point per series ([8] and [14]) instead of 4 filled points. With Period: 300 it fills correctly. Carrying period through mapArray (and pointwise for array operands) would close the last part of M1.

L8. The new wire test misses rows that never come back. server/aws/cloudwatch/metric_math_functions_test.go (TestSDKGetMetricDataArithmeticMissingAsZero)
The loop only checks the rows that are returned. If diff or ratio were dropped, the test would still pass. Asserting len(out.MetricDataResults) == 2, or checking every key of want against the results, would catch that.

Terraform and CLI

  • aws_cloudwatch_dashboard with a FILL(SEARCH(...,300),0) widget and aws_cloudwatch_metric_alarm with IF(m1 > 5, FILL(m1, REPEAT), m1 / 0 + 1) plus an m1 MetricStat: apply, then plan returns 0. After changing the threshold and title, apply and plan return 0 again. Destroy succeeds, and get-dashboard afterwards returns ResourceNotFound.
  • get-dashboard and delete-dashboards on a missing name return ResourceNotFound. Over awsJson1_0 the response is 404 with X-Amzn-Errortype: DashboardNotFoundError and X-Amzn-Query-Error: ResourceNotFound;Sender.

Verdict

Changes requested for H1. With the period bounded at parse time, grid made safe against overflow and a regression test added, this is ready. L2 to L5, L7 and L8 can be follow-ups.

// 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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any positive whole number gets through here, and since the last commit it also becomes the FILL step. With 9223372037, time.Duration(period) * time.Second in grid wraps negative and the fill loop never ends. Live, the handler kept burning CPU and grew to 1.7 GB after the client gave up, and the server could not shut down cleanly. With 2^55 the step is 0 and the request panics with a divide by zero. Separately, float64(int(num.val)) past int64 range differs on arm64 and amd64: arm64 accepts 2^63. Could this bound num.val before the conversion and only allow 1, 5, 10, 20, 30 or a multiple of 60 up to a sane cap?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d8c9c1. searchPeriod now checks the float before converting it (< 1, > math.MaxInt32, not whole), then applies the MetricStat Period rule: 1, 5, 10, 20, 30 or a multiple of 60. That rule now lives in metricmath.ValidPeriod, and the provider's ValidatePeriod from #1703 calls it, so there is one definition. An out-of-range period is a parse error, so the SEARCH reads nothing, and GetMetricData rejects the expression with ValidationError: Error in expression 'e1': Invalid SEARCH period. .... Because the check happens before the conversion, 2^63 is rejected on arm64 and amd64 alike.

return nil
}

step := time.Duration(period) * time.Second

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This multiplication overflows for a large period, and a negative step passes the maxFillPoints check (the quotient is negative), so the loop below never ends. Counting the periods in seconds first, e.g. int64(e.end.Sub(e.start)/time.Second)/int64(period) > maxFillPoints, plus a step <= 0 guard, would make grid safe whatever reaches it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d8c9c1. grid now counts in whole seconds (last := int64(e.end.Sub(e.start)/time.Second) / int64(period)), checks last against maxFillPoints, and builds each point as start + i*period seconds. Every offset is at most the length of the range, so it can't overflow or loop for any period, including one that comes from an entry Period or a MetricStat. TestFILLHugeEntryPeriod passes 9223372037, 2^55, 2^62 and MaxInt64 as the entry Period, with a 5s timeout guard. Before the fix those cases hung or panicked with divide by zero.

Comment thread services/monitoring/metricmath/eval.go Outdated
}

return seriesSeries(op, left.series, right.series)
return result{isArray: true, array: out}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The period from a SEARCH is dropped here, so FILL(SEARCH('{Rev/App,InstanceId}','Sum',300)*2, 0) with no query Period comes back unfilled (one point per series). Copying the input's period onto the mapped result would cover the arithmetic-over-SEARCH case too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d8c9c1 (L7). mapArray now takes the input result and copies its period onto the mapped array, so SEARCH(...)*2 and 2*SEARCH(...) keep the SEARCH step. FILL's array branch reuses it. Covered by TestFILLOverSEARCHArithmeticKeepsPeriod (unit) and a new arithmetic over SEARCH case in TestSDKGetMetricDataFILLOverSEARCHNoPeriod. Live with no query Period, FILL(SEARCH('{Rev/App,InstanceId}','Sum',300)*2,0) now returns 4 points per series.

want := map[string][]float64{"diff": {10, 20, -20, 0}, "ratio": {1.5, 0}}

for _, r := range out.MetricDataResults {
id := aws.ToString(r.Id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If diff or ratio were missing from the response this loop would still pass. Could it assert the result count, or iterate over want?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d8c9c1 (L8). The test now collects the results by Id, checks that the count matches want, and loops over want, so a missing diff or ratio fails.

…dashboards

GetDashboard and DeleteDashboards list DashboardNotFoundError (awsQuery code
ResourceNotFound, HTTP 404) in the CloudWatch model. The rpc-v2-cbor and JSON
codecs wrote __type ResourceNotFound, so aws-sdk-go-v2 fell back to a generic
API error instead of types.DashboardNotFoundError. Write the shape name in
__type and the query code in X-Amzn-Query-Error; the query/XML code stays
ResourceNotFound, which is already correct.

Part of stackshy#1520
…ors in metric math

FILL, IF and SEARCH were stored but parsed as unsupported, so GetMetricData
and metric-math alarms returned no data for them. Implement them following
the CloudWatch user guide (Use metric math, search expression syntax):

- comparison (== != < <= > >=) and logical (AND/&&, OR/||) operators, with a
  point missing from one series read as 0
- IF(cond, a[, b]) with the documented per-point rules and tables
- FILL(x, S|TS|REPEAT|LINEAR) over the periods of the query range
- SEARCH(' {schema} term ', 'Stat'[, period]) over metrics with data in the
  past two weeks; each found metric is its own GetMetricData result row

Part of stackshy#1520
…-goals

GetMetricWidgetImage renders a PNG graph, which the emulator does not do.
Record it, the metric math functions that are evaluated, the labels the AWS
docs leave unstated, and the newer AnomalyDetectorId surface as non-goals.

Part of stackshy#1520
Pointer params for the 96-byte result, operator and keyword constants,
smaller parser and search lexer functions, and drop the nolint:dupl on
deleteMetricStream that the dashboard change made unused.

Part of stackshy#1520
…s at

FILL took its grid from the entry period, which never saw a SEARCH's own
period argument, so FILL(SEARCH(..., 300), 0) on an entry with no Period
returned the sparse series unchanged. A SEARCH now reports the period it
reads at (its argument, else the entry period, else 60s) and FILL uses it.

Part of stackshy#1520
The user guide's arithmetic rule is "Missing values in a time series are
treated as 0. If the value of a data point causes a function to attempt to
divide by zero, the data point is dropped." Arithmetic between two series
dropped any timestamp only one series had, and x/0 returned 0. Both series
now combine over the union of their timestamps with a missing point as 0,
and a division by zero drops the point.

Part of stackshy#1520
…verflow-safe

A SEARCH period argument past the time.Duration range made the FILL grid
step wrap negative (endless loop) or to exactly zero (divide by zero).

- SEARCH's period argument now follows the MetricStat Period rule
  (1, 5, 10, 20, 30 or a multiple of 60, up to the Integer maximum),
  checked on the float before the int conversion. The rule moves to
  metricmath.ValidPeriod and the provider's ValidatePeriod from stackshy#1703
  uses it, so there is a single definition.
- GetMetricData rejects such an expression with a ValidationError
  ("Error in expression 'e1': Invalid SEARCH period. ...").
- The FILL grid counts in whole seconds and never turns the period
  itself into a Duration, so it ends for any period, including an
  entry Period passed by a library caller.
- Arithmetic over a SEARCH keeps the SEARCH period, so FILL around it
  fills every period when the query sets no Period.
- The arithmetic wire test now checks every expected row comes back.
@Satyam-Trivedi-ZS

Copy link
Copy Markdown
Collaborator Author

Thanks for catching this, @NitinKumar004. Head is now 5d8c9c1e.

Rebase. The branch is rebased onto the current development, so it now includes #1703 (Period validation on GetMetricData and GetMetricStatistics). There were no textual conflicts. On the semantic side:

H1, fixed in 5d8c9c1e.

  • searchPeriod bounds the float before converting it (>= 1, <= MaxInt32, whole), then applies the documented MetricStat periods: 1, 5, 10, 20, 30 or a multiple of 60. 2^63 is rejected on every platform.
  • GetMetricData rejects an invalid SEARCH period with 400 ValidationError: Error in expression 'e1': Invalid SEARCH period. Valid periods are 1, 5, 10, 20, 30, 60 and any multiple of 60. The docs give no exact message for this case, so it follows the existing Error in expression format. In the library evaluator, the expression is a parse error and reads nothing.
  • grid counts in whole seconds and builds start + i*period with every offset at most the range length, so it ends for any period from any source: SEARCH, entry Period or MetricStat.
  • Regression tests, each with a timeout so a hang fails fast:
    • TestFILLOverSEARCHHugePeriod: your exact repro, 2^55 and 2^63.
    • TestFILLHugeEntryPeriod: entry Period 9223372037, 2^55, 2^62 and MaxInt64.
    • TestInvalidSearchPeriod and TestValidPeriod.
    • TestGetMetricDataSearchPeriodValidation (query protocol) and TestSDKGetMetricDataSearchPeriodValidation (Go SDK).
    • Before the fix these hung, or panicked with divide by zero.
  • Live with the AWS CLI against serve: the repro and the 2^55 SEARCH return the ValidationError in under a second, and the server stays idle. FILL(SEARCH('{Rev/App,InstanceId}','Sum',300),0) still returns 0,0,4,0 / 0,0,8,0.

Lows

  • L7, fixed. mapArray carries the input's period, so FILL(SEARCH(...,300)*2,0) with no query Period fills every period (live: 0,0,8,0). Tests: unit, plus a new wire case.
  • L8, fixed. The arithmetic wire test checks the result count and loops over want.
  • L2, follow-up. The docs still don't give the PutMetricAlarm rejection code or message for SEARCH.
  • L3, follow-up. Opened aws-cloudwatch: map typed SDK errors over rpc-v2-cbor (writeCBORError) #1716 as you asked.
  • L4, follow-up. Pre-existing. Recheck when the SDK is bumped.
  • L5, follow-up. The docs give the limits but not how they are enforced.

Checks.

  • go build ./....
  • go test ./... passes, all 660 packages.
  • go test -race passes on services/monitoring/..., server/aws/cloudwatch, providers/aws/cloudwatch and persist.
  • go generate ./... leaves no diff.
  • Full golangci-lint (no --new-from-rev) on the three touched trees: 10 findings on the branch versus 11 on development. All are pre-existing, and the one removed is the dupl. No new gocyclo, and no function over 10.

golangci-lint v2.14 reports prealloc on the two slices built while matching
SEARCH terms. Size them up front; behaviour is unchanged.
@Satyam-Trivedi-ZS

Copy link
Copy Markdown
Collaborator Author

One more small commit, 0f7d113: with golangci-lint v2.14.0 (full run, no --new-from-rev) the new SEARCH matcher had two prealloc findings in services/monitoring/metricmath/search.go. Both slices are now sized up front; behaviour is unchanged and the metricmath and CloudWatch tests pass under -race. On ./services/monitoring/... ./server/aws/cloudwatch/... ./providers/aws/cloudwatch/... the branch now reports 30 findings against 31 on development, so it adds none and removes one dupl.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants