Repository navigation
fix(metrics): show the latest complete data point with --latest - #165
Conversation
The metrics API returns 1-minute points before every container has reported. Sampling live projects showed the newest point, and sometimes the previous completed minute, missing containers for ~60-80s, so --latest often listed only some containers. Pick the newest point that has as many services as any point in the response, instead of the newest point with any services. Also fix the error message for an invalid --interval, which referred to --range. Closes #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point
🔍 Full review · 2 files reviewed
Verification
- max([0, ...]) keeps the filter safe when data is empty or every point lacks a 'services' key: maxServices is 0 and the list is left untouched.
- The
$maxServices > 0guard means the in-progress point (no 'services' key, count 0) is never selected by --latest. - The reversed iteration picks the newest point among ties, matching the test's expectation of the 19:02 point over 19:01.
- The --interval error message now names --interval, and it is on the
empty($interval)branch reached by an unparseable value. - The new integration test's fixed epoch timestamps render deterministically because testEnv() sets TZ=UTC.
The diff adds integration-tests/metrics_test.go, which exercises --latest against a mocked observability endpoint and is run by the integration-test job in .github/workflows/ci.yml (make integration-test); the PHP change is covered by the legacy-php job's php-cs-fixer/phpstan lint only. No test covers the case where no point contains the full service set, or where a service disappears mid-range.
Review details
- Commit: 7b5165f
- Model: claude-opus-5
Review 1 of 10 for this pull request · View the full run
Comparing against the maximum service count over the whole range meant a service that stopped reporting (e.g. removed by a deployment) made --latest show the last point before it disappeared, which could be hours old with a long --range. Pick the point with the most services among the last 3 that have any, preferring the newest. Live metrics showed missing services only in the newest 2 points. Reword the help text to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No new issues found · 2 still open
🔁 Incremental · 2 files reviewed
Outstanding from earlier reviews:
- 🟡 #4086856131 —
legacy/src/Command/Metrics/MetricsCommandBase.php:192:--latestcan show hours-old metrics as if current after a service is removed. — Bounded but not closed: the candidate window is 3 points regardless of grain, so after a service is permanently removed --latest still returns a point up to two intervals old — ~2 minutes at grain 60, but hours with e.g. --range 8h --interval 1h, with nothing in the output flagging it as not the newest point. - 🔵 #4086856146 —
legacy/src/Command/Metrics/MetricsCommandBase.php:100: Help text overstates the guarantee, misleading users reading incomplete output. — The overstatement is gone, but 'skipping up to two newer points that are missing services' still misdescribes the code: trailing points with no 'services' key are dropped before the 3-point slice (so more than two newer points can be skipped), and a newer point is also skipped when it merely has fewer services than an older one rather than being incomplete.
Verification
- The new selection loop only inspects points that passed the !empty('services') filter, so \count($item['services']) is never called on the in-progress point that has no 'services' key.
- The >= tie-break makes the newest point win when several of the last three candidates have the same service count, matching the test's expected 19:02:00 row.
- array_values() before array_slice(-3) keeps the slice positional, so gaps left by array_filter's preserved keys cannot shift which points are considered.
- When no point has services, $latest stays null and $items['data'] is left untouched, which is the same behaviour as the previous max-based code.
- The integration test guards every read and write of the shared
dataslice withmu, so the handler goroutine and the test goroutine do not race.
integration-tests/metrics_test.go covers both --latest scenarios against a mocked observability endpoint and runs in the integration-test CI job (.github/workflows/ci.yml, make integration-test, which builds the CLI first); there is no PHP unit test for MetricsCommandBase, so the selection loop is exercised only through that end-to-end test.
Review 2 of 10 for this pull request · View the full run
A fixed 3-point window scales with the grain, so with a coarse --interval a removed service could make --latest show a point hours old. The metrics lag is wall-clock time (~60-80s observed), so only points ending within 2 minutes of the query end are treated as unsettled. Walk back from the newest point, keep the one with the most services, and stop at the first settled point. The result is at most one interval plus 2 minutes old. Also fixes --latest at a 5-minute interval just after a bucket boundary, where the in-progress bucket is missing services. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
legacy/src/Command/Metrics/MetricsCommandBase.php:204— The skip window is a fixed 120 seconds, but the loop can only stop on a whole data point, so the amount of staleness it can introduce is one grain, not two minutes. With the default 60s grain that matches the help text, but the grain grows with --range (the API divides the range when --interval is not given). Runningmetrics:cpu --range 8h --latestafter a worker is removed or scaled to zero: the newest bucket legitimately has one service fewer than the previous bucket,\count($item['services']) > \count($latest['services'])therefore selects the previous bucket, and the loop breaks there because its end is <= _to-120. The command prints a point one grain (potentially tens of minutes) old while describing it as the latest. The same happens with a historical--to, where no settling is needed at all yet the last two points are still compared.
Verification
- With the test's first dataset (_to=1790190300, _grain=60) the loop evaluates the 19:03, 19:02 points and breaks at 19:01, so the asserted 19:02 row is what the code produces.
- The second dataset breaks at 1790190120 with
appnever exceeded, so --latest returns the newest point 19:04 rather than the older two-service point — the earlier unbounded-staleness behaviour is gone. - Points with no
serviceskey are skipped before\count(), so the in-progress point{"timestamp": 1790190240}cannot raise a count-of-null error. _toand_grainare unix seconds elsewhere in the tree (Query sends from/to as ints; commands pass _grain to Duration::humanize), so the integer arithmetic on them is consistent.- The --interval error message now names --interval and the time range, not --range.
integration-tests/metrics_test.go drives both branches (settle-window skip, and a service that stopped reporting) against a mocked observability endpoint; it runs in the integration-test CI job via make integration-test, and no PHPUnit test in legacy covers the new selection loop.
Review 3 of 10 for this pull request · View the full run
A bucket can only be missing services while its start is within the metrics lag; after that every reporting service has data in it. Using the bucket end meant a coarse grain (e.g. --range 8h) always compared the newest two buckets, so a removed service made --latest show a point one grain old. Measuring against the query end also compared points for a historical --to, where all data has settled. Now only points that started within 2 minutes of the current time can be skipped. The integration test data is generated relative to now, and covers a historical query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 2 warnings
🔁 Incremental · 2 files reviewed
Verification
- The reversed scan keeps strict
>on service counts, so among equal-sized points the newest one is still selected. - Points with no
serviceskey (the in-progress minute) are skipped before the settle check, so they can never be returned by --latest. - The new
row()helper's+00:00is a literal in the Go layout and matches the CLI's output because the test harness sets TZ=UTC (integration-tests/tests.go:160). - Switching
--format tsvto add--no-headermatches the expected strings, which no longer contain theTimestamp\tService...header line. - The third dataset (points 60/61 minutes old) is settled on the first iteration, so --latest returns the newest point without skipping.
The diff rewrites integration-tests/metrics_test.go to use now-relative timestamps and adds a third case (older points are not skipped); it runs in the integration-test job of .github/workflows/ci.yml via make integration-test. No test covers a grain larger than 60 seconds, which is where dropping _grain from the settle check changes behaviour.
Review 4 of 10 for this pull request · View the full run
Truncating the test clock to the minute could leave only milliseconds before the "1 minute ago" point became settled for the CLI. Use the unrounded time so it stays recent for a full minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 1 file reviewed
🔵 Minor point
integration-tests/metrics_test.go:49— DroppingTruncate(time.Minute)narrows but does not remove the timing dependency in the first assertion.ts(1)is exactly 60s beforenow, and the PHP selector settles a point whentimestamp <= time() - 120; the loop breaks on the first settled point it sees. The firstlatest()call therefore only yields thets(2)rows if the PHP process reachesprocessQueryless than 60s afternowwas computed. That first invocation runs--versionfor command validation and then the legacy path, which extracts the PHP binary and phar into the cache dir on a cold cache; if that plus process startup exceeds 60s (loaded/parallel CI runner, cold cache, slow disk),ts(1)becomes settled, the loop breaks there and the command prints only thedb 0.4row, failing the first assertTrimmed. The later assertions are unaffected because their expected point is the newest one either way.
Verification
ts(2)is always settled for the PHP check (floor(now)-120 <= time()-120), so the first assertion's chosen point is deterministic on the older side.- Row timestamps still match:
PropertyFormatter::formatDatepreserves seconds and TZ=UTC is set in testEnv, so non-truncatednowrenders identically on both sides. - The second and third data sets select the newest point regardless of elapsed time, so removing Truncate does not make those assertions time-sensitive.
datais read and written undermu, sosetDataand the observability handler do not race.
The change is itself test code: integration-tests/metrics_test.go (TestMetricsLatest), run by the integration-test job in .github/workflows/ci.yml via make integration-test after make single; no other check covers the PHP --latest selection logic.
Review 5 of 10 for this pull request · View the full run
The data was built once before the CLI ran, so a slow first run (e.g. extracting the phar on a cold cache) of over a minute would make the recent point settled and fail the test. Build the points relative to each request instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · ⚪ 1 nitpick
🔁 Incremental · 1 file reviewed
⚪ Nitpick
integration-tests/metrics_test.go:88— The mocked response advertises_from: ts(10)(ten minutes back, matching the CLI's default 10-minute range request), but the last case serves points at 60 and 61 minutes ago, i.e. entirely outside the window the handler itself claims to cover. The real observability API never returns points outside[_from,_to], so the "Older points are not skipped" case exercises a response shape that cannot occur; using points a few minutes old with a wider--rangewould test the same settle-window behaviour realistically.
Verification
- Moving
nowinto the handler and computingrow(...)after eachf.Runremoves the stale-timestamp ordering hazard: expectations now use the samenowas the response they assert on. nowanddataare written undermuin the handler and read undermuinrow/setData, andts/pointare only invoked while the lock is held, so there is no unguarded access or self-deadlock.- With the handler's per-request
now, the point at ts(2) is exactlytime()-120when PHP checks it, so it always counts as settled and the loop deterministically stops there — matching the first expectation. - The 60/61-minute case still passes the PHP loop:
buildRowsandprocessQuerynever filter points against_from/_to, so the older points are returned rather than dropped. go veton the integration-tests package accepts the new closure-typeddata/setDatasignatures.
Verified by the new TestMetricsLatest in integration-tests/metrics_test.go, which mocks the observability overview endpoint and is executed by the integration-test job in .github/workflows/ci.yml (make integration-test); no PHP-side unit test covers the --latest selection loop in MetricsCommandBase::processQuery.
Review 6 of 10 for this pull request · View the full run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve conflicts with the --latest fix (#165) and the observability entrypoint lookup (#118): - MetricsCommandBase: keep both STORAGE_MOUNTPOINT and LATEST_SETTLE_TIME. - Move the storage tests to metrics_storage_test.go, next to the --latest tests in metrics_test.go. - Serve the observability entrypoint (_links.resources_overview) in both tests' mocks, as the resources overview URL is now discovered there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #118, the metrics commands find the resources overview URL via the "resources_overview" link from GET {environment}/observability/, instead of the environment's "#observability-pipeline" link. TestMetricsLatest (#165) was written against the old lookup, so it failed on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#167) Since #118, the metrics commands discover the resources overview URL via GET {environment}/observability/ instead of the #observability-pipeline link. TestMetricsLatest (#165) still mocked the old link, so the CLI got a 404 and failed with "Observability API link not found". Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… plans (#50) * fix: Clarify why certain functions are not available for upsun fixed * Update error message for unsupported API usage * fix(legacy): mention Upsun Fixed only for projects in Fixed organizations The "flexible resources API is not enabled" error is shared by all the resources:* commands, so move it to ResourcesUtil and use it everywhere instead of only in resources:build:get. A disabled sizing API does not by itself mean the project is on Upsun Fixed, so the Fixed note is now only shown when the project's organization has the "fixed" type. If the organization cannot be loaded (e.g. the user has no access to it), the note is skipped. The docs link uses the docs.upsun.com/anchors/fixed/ redirect, like other Fixed links in the CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(metrics): mock the observability entrypoint in TestMetricsLatest Since #118, the metrics commands find the resources overview URL via the "resources_overview" link from GET {environment}/observability/, instead of the environment's "#observability-pipeline" link. TestMetricsLatest (#165) was written against the old lookup, so it failed on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(legacy): configure the Fixed docs URL and avoid extra API calls Address review feedback on the sizing API error: - Read the docs link from a new service.fixed_docs_url config key, and drop the "Upsun" brand from the message, so vendor builds without the key show no Fixed note. - Skip the organization lookup when api.organizations is disabled. - Read the project's organization without lazy loading, so a missing property cannot trigger an uncaught API request. - Assert on the note and URL in the test, rather than any "fixed" text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Matthias Van Woensel <3532563+matthiaz@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The metrics API returns 1-minute points before every container has reported, so
--latestoften lists only some containers (#45).Sampling live projects every 15s showed new points take ~60-80s to include all containers, and the previous completed minute is sometimes still missing some.
--latestnow picks the newest point that has as many services as any point in the response, instead of the newest point with any services. The default grain is unchanged.This supersedes #49, which used a 5-minute grain. The API aligns 5-minute buckets to clock boundaries and returns the in-progress bucket, so that bucket has the same gap for about a minute after each boundary, and it turns "latest" into a 5-minute average.
Also fixes the invalid
--intervalerror message, which referred to--range(from #49).Adds an integration test with a mocked observability endpoint.
Closes #45
🤖 Generated with Claude Code