Skip to content

Commit 0d8b028

Browse files
pjcdawkinsclaude
andauthored
feat(metrics): show object storage usage (#303)
The metrics API reports an app's object storage under the "object storage" mountpoint, with the bytes used, the quota (omitted when it is 0) and the object count (as inodes_used). The CLI showed the quota in resources:get but no usage to compare it with. - metrics:all: add object_storage_used, object_storage_limit, object_storage_percent and object_storage_count columns, with object_storage_percent shown by default. - metrics:disk-usage: add object_used, object_limit, object_percent and object_count columns, with object_used, object_limit and object_percent shown by default. - In tables, the columns are shown if a returned service reports object storage, or a returned app has an object storage quota above 0. Machine-readable formats always include them, like storage columns. - Missing object storage metrics are shown as empty values. - The api.metrics_storage config key also turns these columns off. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d2c9f54 commit 0d8b028

8 files changed

Lines changed: 265 additions & 89 deletions

File tree

‎integration-tests/metrics_options_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func TestMetricsTimeOptions(t *testing.T) {
6060
for _, c := range cases {
6161
t.Run(c.name, func(t *testing.T) {
6262
t.Parallel()
63-
f, apiHandler, projectID := setupMetricsTest(t, false, false)
63+
f, apiHandler, projectID := setupMetricsTest(t, metricsTestOptions{})
6464
args := append([]string{"metrics:cpu", "-p", projectID, "-e", "main", "--to", to, "--format", "csv"}, c.args...)
6565
_, stderr, err := f.RunCombinedOutput(args...)
6666
requests := apiHandler.Requests("GET", "/projects/*/environments/main/observability/resources/overview")
@@ -103,7 +103,7 @@ func TestMetricsServiceOptions(t *testing.T) {
103103
for _, c := range cases {
104104
t.Run(c.name, func(t *testing.T) {
105105
t.Parallel()
106-
f, apiHandler, projectID := setupMetricsTest(t, false, false)
106+
f, apiHandler, projectID := setupMetricsTest(t, metricsTestOptions{})
107107
args := append([]string{"metrics:memory", "-p", projectID, "-e", "main", "--format", "csv"}, c.args...)
108108
_, stderr, err := f.RunCombinedOutput(args...)
109109
if c.wantErr != "" {
@@ -160,7 +160,7 @@ func TestMetricsDisplayOptions(t *testing.T) {
160160
for _, c := range cases {
161161
t.Run(c.name, func(t *testing.T) {
162162
t.Parallel()
163-
f, _, projectID := setupMetricsTest(t, false, false)
163+
f, _, projectID := setupMetricsTest(t, metricsTestOptions{})
164164
args := append(slices.Clone(c.args), "-p", projectID, "-e", "main", "--format", "csv", "--date-fmt", "c")
165165
stdout, stderr, err := f.RunCombinedOutput(args...)
166166
require.NoError(t, err, stderr)

‎integration-tests/metrics_storage_test.go‎

Lines changed: 124 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ func mountpointMetrics(diskUsed, diskLimit, inodesUsed, inodesLimit float64) map
2222
}
2323
}
2424

25+
// objectStorageMetrics has no inodes limit, like the API.
26+
var objectStorageMetrics = map[string]any{
27+
"disk_used": map[string]any{"avg": 400},
28+
"disk_limit": map[string]any{"max": 1000},
29+
"inodes_used": map[string]any{"avg": 25},
30+
}
31+
2532
// filterServices applies the services[] query filter, like the API.
2633
func filterServices(req *http.Request, services map[string]any) map[string]any {
2734
var names []string
@@ -42,7 +49,14 @@ func filterServices(req *http.Request, services map[string]any) map[string]any {
4249
return filtered
4350
}
4451

45-
func setupMetricsTest(t *testing.T, withStorage, withStorageMount bool) (
52+
type metricsTestOptions struct {
53+
storage bool // Services report network storage.
54+
storageMount bool // The app has a storage mount.
55+
objectStorage map[string]any // The app's object storage metrics, if any.
56+
objectQuota int // The app's object storage quota (MB).
57+
}
58+
59+
func setupMetricsTest(t *testing.T, opts metricsTestOptions) (
4660
f *cmdFactory, apiHandler *mockapi.Handler, projectID string,
4761
) {
4862
authServer := mockapi.NewAuthServer(t)
@@ -63,9 +77,12 @@ func setupMetricsTest(t *testing.T, withStorage, withStorageMount bool) (
6377
main := makeEnv(projectID, "main", "production", "active", nil)
6478
obsPath := "/projects/" + projectID + "/environments/main/observability"
6579
app := mockapi.App{Name: "app", Type: "php:8.4", Size: "AUTO"}
66-
if withStorageMount {
80+
if opts.storageMount {
6781
app.Mounts = map[string]mockapi.Mount{"/files": {Source: "storage", SourcePath: "files"}}
6882
}
83+
if opts.objectQuota > 0 {
84+
app.Resources = map[string]any{"disk": map[string]any{"object": opts.objectQuota}}
85+
}
6986
main.SetCurrentDeployment(&mockapi.Deployment{
7087
WebApps: map[string]mockapi.App{
7188
"app": app,
@@ -106,7 +123,7 @@ func setupMetricsTest(t *testing.T, withStorage, withStorageMount bool) (
106123
"/mnt": mountpointMetrics(250, 1000, 5, 1000),
107124
"/tmp": mountpointMetrics(10, 1000, 1, 1000),
108125
}
109-
if withStorage {
126+
if opts.storage {
110127
appMounts["storage"] = mountpointMetrics(920, 1000, 30, 100)
111128
// Storage with a partial set of inode metrics.
112129
dbMounts["storage"] = map[string]any{
@@ -115,6 +132,9 @@ func setupMetricsTest(t *testing.T, withStorage, withStorageMount bool) (
115132
"inodes_limit": map[string]any{"max": 100},
116133
}
117134
}
135+
if opts.objectStorage != nil {
136+
appMounts["object storage"] = opts.objectStorage
137+
}
118138
_ = json.NewEncoder(w).Encode(map[string]any{
119139
"_grain": 60,
120140
"_from": 1790189400,
@@ -135,78 +155,138 @@ func setupMetricsTest(t *testing.T, withStorage, withStorageMount bool) (
135155
func TestMetricsStorage(t *testing.T) {
136156
t.Parallel()
137157
cases := []struct {
138-
name string
139-
withStorage bool
140-
withStorageMount bool
141-
env []string
142-
args []string
143-
want string
144-
notWant string
158+
name string
159+
opts metricsTestOptions
160+
env []string
161+
args []string
162+
want string
163+
notWant string
145164
}{
146165
{
147-
name: "all storage columns",
148-
withStorage: true,
166+
name: "all storage columns",
167+
opts: metricsTestOptions{storage: true},
149168
args: []string{"metrics:all", "-1", "--no-header", "--format", "plain",
150169
"-c", "service,disk_percent,storage_percent,storage_inodes_percent"},
151170
want: "app\t10.0%\t92.0%\t30.0%\ndb\t25.0%\t50.0%\t\n",
152171
},
153172
{
154-
name: "all table shows storage when present",
155-
withStorage: true,
156-
args: []string{"metrics:all", "-1"},
157-
want: "Storage %",
173+
name: "all table shows storage when present",
174+
opts: metricsTestOptions{storage: true},
175+
args: []string{"metrics:all", "-1"},
176+
want: "Storage %",
158177
},
159178
{
160179
name: "all table hides storage when absent",
161180
args: []string{"metrics:all", "-1"},
162181
notWant: "Storage",
163182
},
164183
{
165-
name: "all table shows storage when the deployment has storage mounts",
166-
withStorageMount: true,
167-
args: []string{"metrics:all", "-1"},
168-
want: "Storage %",
184+
name: "all table shows storage when the deployment has storage mounts",
185+
opts: metricsTestOptions{storageMount: true},
186+
args: []string{"metrics:all", "-1"},
187+
want: "Storage %",
169188
},
170189
{
171-
name: "all table ignores storage mounts of filtered-out apps",
172-
withStorageMount: true,
173-
args: []string{"metrics:all", "-1", "-s", "db"},
174-
notWant: "Storage",
190+
name: "all table ignores storage mounts of filtered-out apps",
191+
opts: metricsTestOptions{storageMount: true},
192+
args: []string{"metrics:all", "-1", "-s", "db"},
193+
notWant: "Storage",
175194
},
176195
{
177196
name: "all csv always includes storage",
178197
args: []string{"metrics:all", "-1", "--format", "csv"},
179-
want: "/tmp inodes %,Storage %,Storage inodes %\n",
198+
want: "/tmp inodes %,Storage %,Storage inodes %,Object storage %\n",
180199
},
181200
{
182-
name: "disk-usage storage columns",
183-
withStorage: true,
201+
name: "all object storage columns",
202+
opts: metricsTestOptions{objectStorage: objectStorageMetrics},
203+
args: []string{"metrics:all", "-1", "--no-header", "-B", "--format", "plain",
204+
"-c", "service,object_storage_used,object_storage_limit,object_storage_percent,object_storage_count"},
205+
want: "app\t400\t1000\t40.0%\t25\ndb\t\t\t\t\n",
206+
},
207+
{
208+
name: "all object storage without a quota",
209+
opts: metricsTestOptions{objectStorage: map[string]any{
210+
"disk_used": map[string]any{"avg": 400},
211+
"inodes_used": map[string]any{"avg": 25},
212+
}},
213+
args: []string{"metrics:all", "-1", "--no-header", "-B", "--format", "plain", "-s", "app",
214+
"-c", "service,object_storage_used,object_storage_limit,object_storage_percent,object_storage_count"},
215+
want: "app\t400\t\t\t25\n",
216+
},
217+
{
218+
name: "all table shows object storage when present",
219+
opts: metricsTestOptions{objectStorage: objectStorageMetrics},
220+
args: []string{"metrics:all", "-1"},
221+
want: "Object storage %",
222+
},
223+
{
224+
name: "all table hides object storage when absent",
225+
args: []string{"metrics:all", "-1"},
226+
notWant: "Object",
227+
},
228+
{
229+
name: "all table shows object storage when the app has a quota",
230+
opts: metricsTestOptions{objectQuota: 1024},
231+
args: []string{"metrics:all", "-1"},
232+
want: "Object storage %",
233+
},
234+
{
235+
name: "all table ignores object storage quotas of filtered-out apps",
236+
opts: metricsTestOptions{objectQuota: 1024},
237+
args: []string{"metrics:all", "-1", "-s", "db"},
238+
notWant: "Object",
239+
},
240+
{
241+
name: "disk-usage storage columns",
242+
opts: metricsTestOptions{storage: true},
184243
args: []string{"disk", "-1", "--no-header", "-B", "--format", "plain",
185244
"-c", "service,storage_used,storage_limit,storage_percent,storage_ipercent"},
186245
want: "app\t920\t1000\t92.0%\t30.0%\ndb\t500\t1000\t50.0%\t\n",
187246
},
188247
{
189-
name: "disk-usage table shows storage when present",
190-
withStorage: true,
191-
args: []string{"disk", "-1"},
192-
want: "Storage used",
248+
name: "disk-usage table shows storage when present",
249+
opts: metricsTestOptions{storage: true},
250+
args: []string{"disk", "-1"},
251+
want: "Storage used",
193252
},
194253
{
195254
name: "disk-usage csv always includes storage",
196255
args: []string{"disk", "-1", "--format", "csv"},
197-
want: "/tmp %,Storage used,Storage limit,Storage %,Storage inodes %\n",
256+
want: "/tmp %,Storage used,Storage limit,Storage %,Storage inodes %," +
257+
"Object storage used,Object storage limit,Object storage %\n",
258+
},
259+
{
260+
name: "disk-usage object storage columns",
261+
opts: metricsTestOptions{objectStorage: objectStorageMetrics},
262+
args: []string{"disk", "-1", "--no-header", "-B", "--format", "plain", "-s", "app",
263+
"-c", "service,object_used,object_limit,object_percent,object_count"},
264+
want: "app\t400\t1000\t40.0%\t25\n",
265+
},
266+
{
267+
name: "disk-usage table shows object storage when present",
268+
opts: metricsTestOptions{objectStorage: objectStorageMetrics},
269+
args: []string{"disk", "-1"},
270+
want: "Object storage used",
198271
},
199272
{
200-
name: "disabled",
201-
withStorage: true,
202-
env: []string{"TEST_CLI_API_METRICS_STORAGE=0"},
203-
args: []string{"metrics:all", "-1", "--format", "csv"},
204-
notWant: "Storage",
273+
name: "disabled",
274+
opts: metricsTestOptions{storage: true},
275+
env: []string{"TEST_CLI_API_METRICS_STORAGE=0"},
276+
args: []string{"metrics:all", "-1", "--format", "csv"},
277+
notWant: "Storage",
278+
},
279+
{
280+
name: "disabled object storage",
281+
opts: metricsTestOptions{objectStorage: objectStorageMetrics},
282+
env: []string{"TEST_CLI_API_METRICS_STORAGE=0"},
283+
args: []string{"disk", "-1", "--format", "csv"},
284+
notWant: "Object",
205285
},
206286
}
207287
for _, c := range cases {
208288
t.Run(c.name, func(t *testing.T) {
209-
f, _, projectID := setupMetricsTest(t, c.withStorage, c.withStorageMount)
289+
f, _, projectID := setupMetricsTest(t, c.opts)
210290
// The terminal is wide enough for headers not to wrap.
211291
f.extraEnv = append([]string{"COLUMNS=500"}, c.env...)
212292
args := append([]string{}, c.args...)
@@ -226,10 +306,12 @@ func TestMetricsStorage(t *testing.T) {
226306

227307
func TestMetricsStorageDisabledColumn(t *testing.T) {
228308
t.Parallel()
229-
f, _, projectID := setupMetricsTest(t, true, false)
309+
f, _, projectID := setupMetricsTest(t, metricsTestOptions{storage: true, objectStorage: objectStorageMetrics})
230310
f.extraEnv = []string{"TEST_CLI_API_METRICS_STORAGE=0"}
231311

232-
_, stderr, err := f.RunCombinedOutput("disk", "-1", "-c", "storage_used", "-p", projectID, "-e", "main")
233-
assert.Error(t, err)
234-
assert.Contains(t, stderr, "Column not found: storage_used")
312+
for _, col := range []string{"storage_used", "object_used"} {
313+
_, stderr, err := f.RunCombinedOutput("disk", "-1", "-c", col, "-p", projectID, "-e", "main")
314+
assert.Error(t, err)
315+
assert.Contains(t, stderr, "Column not found: "+col)
316+
}
235317
}

‎legacy/config-defaults.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ api:
275275
# "auto" (from the Observability API if it is available for the environment, otherwise SSH).
276276
log_protocol: ssh
277277

278-
# Whether to show network storage metrics (the "storage" mountpoint) in metrics commands.
278+
# Whether to show network storage and object storage metrics in metrics commands.
279279
metrics_storage: true
280280

281281
# Whether the Flexible Resources API (AKA sizing/scaling) is enabled.

‎legacy/phpstan-baseline.neon‎

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,7 +1411,7 @@ parameters:
14111411
path: src/Command/Metrics/AllMetricsCommand.php
14121412

14131413
-
1414-
message: '#^Parameter \#5 \$environment of method Platformsh\\Cli\\Command\\Metrics\\MetricsCommandBase\:\:storageColumns\(\) expects Platformsh\\Client\\Model\\Environment, mixed given\.$#'
1414+
message: '#^Parameter \#6 \$environment of method Platformsh\\Cli\\Command\\Metrics\\MetricsCommandBase\:\:storageColumns\(\) expects Platformsh\\Client\\Model\\Environment, mixed given\.$#'
14151415
identifier: argument.type
14161416
count: 1
14171417
path: src/Command/Metrics/AllMetricsCommand.php
@@ -1519,7 +1519,7 @@ parameters:
15191519
path: src/Command/Metrics/DiskUsageCommand.php
15201520

15211521
-
1522-
message: '#^Parameter \#5 \$environment of method Platformsh\\Cli\\Command\\Metrics\\MetricsCommandBase\:\:storageColumns\(\) expects Platformsh\\Client\\Model\\Environment, mixed given\.$#'
1522+
message: '#^Parameter \#6 \$environment of method Platformsh\\Cli\\Command\\Metrics\\MetricsCommandBase\:\:storageColumns\(\) expects Platformsh\\Client\\Model\\Environment, mixed given\.$#'
15231523
identifier: argument.type
15241524
count: 1
15251525
path: src/Command/Metrics/DiskUsageCommand.php
@@ -1575,7 +1575,7 @@ parameters:
15751575
-
15761576
message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#'
15771577
identifier: foreach.nonIterable
1578-
count: 5
1578+
count: 3
15791579
path: src/Command/Metrics/MetricsCommandBase.php
15801580

15811581
-
@@ -1596,16 +1596,10 @@ parameters:
15961596
count: 3
15971597
path: src/Command/Metrics/MetricsCommandBase.php
15981598

1599-
-
1600-
message: '#^Cannot access offset ''mountpoints'' on mixed\.$#'
1601-
identifier: offsetAccess.nonOffsetAccessible
1602-
count: 1
1603-
path: src/Command/Metrics/MetricsCommandBase.php
1604-
16051599
-
16061600
message: '#^Cannot access offset ''services'' on mixed\.$#'
16071601
identifier: offsetAccess.nonOffsetAccessible
1608-
count: 4
1602+
count: 3
16091603
path: src/Command/Metrics/MetricsCommandBase.php
16101604

16111605
-
@@ -1614,12 +1608,6 @@ parameters:
16141608
count: 1
16151609
path: src/Command/Metrics/MetricsCommandBase.php
16161610

1617-
-
1618-
message: '#^Cannot access offset ''storage'' on mixed\.$#'
1619-
identifier: offsetAccess.nonOffsetAccessible
1620-
count: 1
1621-
path: src/Command/Metrics/MetricsCommandBase.php
1622-
16231611
-
16241612
message: '#^Cannot access offset ''timestamp'' on mixed\.$#'
16251613
identifier: offsetAccess.nonOffsetAccessible
@@ -1713,7 +1701,7 @@ parameters:
17131701
-
17141702
message: '#^Possibly invalid array key type mixed\.$#'
17151703
identifier: offsetAccess.invalidOffset
1716-
count: 6
1704+
count: 5
17171705
path: src/Command/Metrics/MetricsCommandBase.php
17181706

17191707
-

‎legacy/src/Command/Metrics/AllMetricsCommand.php‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ class AllMetricsCommand extends MetricsCommandBase
6161
'storage_inodes_used' => 'Storage inodes used',
6262
'storage_inodes_limit' => 'Storage inodes limit',
6363
'storage_inodes_percent' => 'Storage inodes %',
64+
65+
'object_storage_used' => 'Object storage used',
66+
'object_storage_limit' => 'Object storage limit',
67+
'object_storage_percent' => 'Object storage %',
68+
'object_storage_count' => 'Object count',
6469
];
6570

6671
/** @var string[] */
@@ -215,9 +220,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
215220
];
216221
if ($this->storageMetricsEnabled()) {
217222
$fields += $this->storageFields($bytes, 'storage_inodes_');
223+
$fields += $this->objectStorageFields($bytes, 'object_storage_');
218224
}
219225
$rows = $this->buildRows($values, $fields, $environment);
220-
[$header, $defaultColumns] = $this->storageColumns(self::TABLE_HEADER, $this->defaultColumns, $values, ['storage_percent', 'storage_inodes_percent'], $environment);
226+
[$header, $defaultColumns] = $this->storageColumns(self::TABLE_HEADER, $this->defaultColumns, $values, ['storage_percent', 'storage_inodes_percent'], ['object_storage_percent'], $environment);
221227

222228
if (!$this->table->formatIsMachineReadable()) {
223229
$formatter = $this->propertyFormatter;

0 commit comments

Comments
 (0)