Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## [Unreleased]

### Added
- Container memory utilization metrics for percentage-based alerts and dashboards

## [0.4.0-beta.7] - 2026-08-23

Seventh beta of the Albacore release, focused on reliable first deployments through the CLI.
Expand Down
15 changes: 15 additions & 0 deletions internal/observ/alert_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,21 @@ func TestAlertRulesRoundTripThroughTheAPI(t *testing.T) {
}
}

func TestContainerMemoryUtilizationRuleThroughTheAPI(t *testing.T) {
h, engine, _ := alertHandler(t)
body := `[{"name":"Memory high","metric":"container.memory.utilization","comparison":"above","threshold":90,"for_seconds":300,"enabled":true}]`
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, "/alerts/rules", strings.NewReader(body)))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
rules := engine.Rules()
if len(rules) != 1 || rules[0].Metric != MetricMemoryUtilization || rules[0].Threshold != 90 {
t.Errorf("engine rules = %+v", rules)
}
}

func TestAlertRulesRejectAnUnusableRule(t *testing.T) {
h, engine, _ := alertHandler(t)

Expand Down
4 changes: 2 additions & 2 deletions internal/observ/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (r AlertRule) forDuration() time.Duration {

func knownMetric(name string) bool {
switch name {
case MetricCPUUsage, MetricMemoryUsage, MetricMemoryLimit, MetricNetworkRx, MetricNetworkTx,
case MetricCPUUsage, MetricMemoryUsage, MetricMemoryLimit, MetricMemoryUtilization, MetricNetworkRx, MetricNetworkTx,
MetricHostCPU, MetricHostMemUtil, MetricHostMemUsage, MetricHostMemLimit, MetricHostDisk:
return true
}
Expand Down Expand Up @@ -171,7 +171,7 @@ func (e AlertEvent) Message() string {

func formatMetricValue(metric string, v float64) string {
switch metric {
case MetricCPUUsage, MetricHostCPU, MetricHostMemUtil, MetricHostDisk:
case MetricCPUUsage, MetricMemoryUtilization, MetricHostCPU, MetricHostMemUtil, MetricHostDisk:
return fmt.Sprintf("%.1f%%", v)
case MetricNetworkRx, MetricNetworkTx:
return formatBytes(v) + "/s"
Expand Down
9 changes: 9 additions & 0 deletions internal/observ/alerts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,13 @@ func TestAlertEventMessage(t *testing.T) {
if got := mem.Message(); got != "Memory: shop-db in shop is 5.0 GB, above 4.0 GB." {
t.Errorf("Message() = %q", got)
}

utilization := AlertEvent{
RuleName: "Memory utilization", Deployment: "shop", Container: "shop-db",
Metric: MetricMemoryUtilization, Value: 92.5, Threshold: 90,
Comparison: ComparisonAbove, State: AlertFiring,
}
if got := utilization.Message(); got != "Memory utilization: shop-db in shop is 92.5%, above 90.0%." {
t.Errorf("Message() = %q", got)
}
}
4 changes: 2 additions & 2 deletions internal/observ/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
func TestHandlerLatestGroupsByDeployment(t *testing.T) {
store := NewStore(10)
now := time.Unix(1_700_000_000, 0)
store.Record(ContainerSample{Deployment: "shop", Container: "shop-web", CPUPercent: 10, MemoryUsage: 200}, now)
store.Record(ContainerSample{Deployment: "shop", Container: "shop-web", CPUPercent: 10, MemoryUsage: 200, MemoryLimit: 1000}, now)
store.Record(ContainerSample{Deployment: "shop", Container: "shop-db", CPUPercent: 4, MemoryUsage: 500}, now)

rec := httptest.NewRecorder()
Expand All @@ -37,7 +37,7 @@ func TestHandlerLatestGroupsByDeployment(t *testing.T) {
web = &got[0].Containers[i]
}
}
if web == nil || web.Metrics[MetricCPUUsage] != 10 || web.Metrics[MetricMemoryUsage] != 200 {
if web == nil || web.Metrics[MetricCPUUsage] != 10 || web.Metrics[MetricMemoryUsage] != 200 || web.Metrics[MetricMemoryUtilization] != 20 {
t.Errorf("shop-web container = %+v", web)
}
}
Expand Down
3 changes: 2 additions & 1 deletion internal/observ/otlp.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func registerGauges(meter metric.Meter, store *Store) error {
MetricCPUUsage,
MetricMemoryUsage,
MetricMemoryLimit,
MetricMemoryUtilization,
MetricNetworkRx,
MetricNetworkTx,
} {
Expand Down Expand Up @@ -113,7 +114,7 @@ func registerGauges(meter metric.Meter, store *Store) error {
// bytes as bytes rather than a bare number.
func metricUnit(name string) string {
switch name {
case MetricCPUUsage:
case MetricCPUUsage, MetricMemoryUtilization:
return "%"
case MetricMemoryUsage, MetricMemoryLimit, MetricNetworkRx, MetricNetworkTx:
return "By"
Expand Down
3 changes: 3 additions & 0 deletions internal/observ/otlp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,7 @@ func TestMetricUnits(t *testing.T) {
if got := metricUnit(MetricCPUUsage); got != "%" {
t.Errorf("cpu unit = %q, want %%", got)
}
if got := metricUnit(MetricMemoryUtilization); got != "%" {
t.Errorf("memory utilization unit = %q, want %%", got)
}
}
11 changes: 6 additions & 5 deletions internal/observ/prometheus.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ const prometheusContentType = "text/plain; version=0.0.4; charset=utf-8"

// metricHelp describes each series for anything reading the exposition.
var metricHelp = map[string]string{
MetricCPUUsage: "Container CPU usage as a percentage of host capacity.",
MetricMemoryUsage: "Container memory usage in bytes.",
MetricMemoryLimit: "Container memory limit in bytes.",
MetricNetworkRx: "Bytes received by the container per second.",
MetricNetworkTx: "Bytes sent by the container per second.",
MetricCPUUsage: "Container CPU usage as a percentage of host capacity.",
MetricMemoryUsage: "Container memory usage in bytes.",
MetricMemoryLimit: "Container memory limit in bytes.",
MetricMemoryUtilization: "Container memory usage as a percentage of its effective limit.",
MetricNetworkRx: "Bytes received by the container per second.",
MetricNetworkTx: "Bytes sent by the container per second.",
}

// renderPrometheus writes the store's latest sample per series in Prometheus text exposition
Expand Down
5 changes: 5 additions & 0 deletions internal/observ/prometheus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ func TestRenderPrometheus(t *testing.T) {
Sample: Sample{Time: at, Value: 12.5}},
{SeriesKey: SeriesKey{Deployment: "shop", Container: "shop-db", Metric: MetricMemoryUsage},
Sample: Sample{Time: at, Value: 5_368_709_120}},
{SeriesKey: SeriesKey{Deployment: "shop", Container: "shop-db", Metric: MetricMemoryUtilization},
Sample: Sample{Time: at, Value: 92.5}},
})

// Prometheus allows only letters, digits and underscores, so the OTel dots convert.
Expand All @@ -36,6 +38,9 @@ func TestRenderPrometheus(t *testing.T) {
if !strings.Contains(out, `container_memory_usage{deployment="shop",container="shop-db"} 5.36870912e+09`) {
t.Errorf("memory sample line wrong:\n%s", out)
}
if !strings.Contains(out, `container_memory_utilization{deployment="shop",container="shop-db"} 92.5`) {
t.Errorf("memory utilization sample line wrong:\n%s", out)
}
}

func TestRenderPrometheusEscapesLabelValues(t *testing.T) {
Expand Down
19 changes: 12 additions & 7 deletions internal/observ/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import (
// OpenTelemetry container metric names (semconv). Emitting these verbatim keeps FlatRun's
// metrics interoperable with any OTel backend.
const (
MetricCPUUsage = "container.cpu.usage"
MetricMemoryUsage = "container.memory.usage"
MetricMemoryLimit = "container.memory.limit"
MetricNetworkRx = "container.network.io.rx"
MetricNetworkTx = "container.network.io.tx"
MetricCPUUsage = "container.cpu.usage"
MetricMemoryUsage = "container.memory.usage"
MetricMemoryLimit = "container.memory.limit"
MetricMemoryUtilization = "container.memory.utilization"
MetricNetworkRx = "container.network.io.rx"
MetricNetworkTx = "container.network.io.tx"
)

// Host (system-wide) metric names, semconv system.* conventions. These answer
Expand Down Expand Up @@ -135,13 +136,17 @@ func (s *Store) add(key SeriesKey, sample Sample) {
// not a total that only climbs.
func (s *Store) Record(c ContainerSample, t time.Time) {
rxRate, txRate := s.netRates(c, t)
s.record(SeriesKey{Deployment: c.Deployment, Container: c.Container}, map[string]float64{
metrics := map[string]float64{
MetricCPUUsage: c.CPUPercent,
MetricMemoryUsage: float64(c.MemoryUsage),
MetricMemoryLimit: float64(c.MemoryLimit),
MetricNetworkRx: rxRate,
MetricNetworkTx: txRate,
}, t)
}
if c.MemoryLimit > 0 {
metrics[MetricMemoryUtilization] = float64(c.MemoryUsage) / float64(c.MemoryLimit) * 100
}
s.record(SeriesKey{Deployment: c.Deployment, Container: c.Container}, metrics, t)
}

// netRates converts a container's cumulative network counters into a per-second
Expand Down
19 changes: 17 additions & 2 deletions internal/observ/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ func TestStoreRecordExpandsSemconvSeries(t *testing.T) {
}, t0)

keys := s.Series()
if len(keys) != 5 {
t.Fatalf("expected 5 semconv series, got %d: %+v", len(keys), keys)
if len(keys) != 6 {
t.Fatalf("expected 6 semconv series, got %d: %+v", len(keys), keys)
}

cpu := s.Range(SeriesKey{Deployment: "shop", Container: "shop-web", Metric: MetricCPUUsage}, t0)
Expand All @@ -26,6 +26,21 @@ func TestStoreRecordExpandsSemconvSeries(t *testing.T) {
if len(mem) != 1 || mem[0].Value != 100 {
t.Errorf("memory series = %+v, want one sample of 100", mem)
}
utilization := s.Range(SeriesKey{Deployment: "shop", Container: "shop-web", Metric: MetricMemoryUtilization}, t0)
if len(utilization) != 1 || utilization[0].Value != 10 {
t.Errorf("memory utilization series = %+v, want one sample of 10", utilization)
}
}

func TestStoreOmitsMemoryUtilizationWithoutALimit(t *testing.T) {
s := NewStore(10)
t0 := time.Unix(1_700_000_000, 0)
s.Record(ContainerSample{Deployment: "shop", Container: "shop-web", MemoryUsage: 100}, t0)

got := s.Range(SeriesKey{Deployment: "shop", Container: "shop-web", Metric: MetricMemoryUtilization}, t0)
if got != nil {
t.Errorf("memory utilization without a limit = %+v, want no series", got)
}
}

func TestStoreNetworkCounterBecomesRate(t *testing.T) {
Expand Down
Loading