[MM-67976] Add server uptime to support packet (#35838)

* MM-67976: Add server uptime to support packet

Add process start time (started_at) and approximate host start time
(host_started_at, Linux only, derived from /proc/uptime) to the support
packet diagnostics. This helps diagnose restart loops and distinguish
process restarts from full machine/container reboots.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Add HostStartedAt assertions to support packet happy-path test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert unrelated changes

* Add unit tests for getHostUptimeSeconds and fix cross-platform portability

- Extract parseUptimeFile helper in uptime_linux.go to enable unit testing
  with synthetic file paths without touching the real /proc/uptime
- Add uptime_linux_test.go covering all error paths (file read error,
  empty file, non-numeric value) and happy-path parsing
- Add uptime_other_test.go asserting the non-Linux stub returns
  ErrHostUptimeUnsupportedPlatform and zero seconds
- Guard HostStartedAt assertions in support_packet_test.go with
  runtime.GOOS so the test correctly asserts zero-time on non-Linux
  platforms instead of failing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: use t.TempDir() for guaranteed-missing path in uptime test

Replace the hard-coded /nonexistent/proc/uptime path with a path
constructed from t.TempDir() to guarantee the file is absent without
relying on filesystem layout assumptions. Also remove the misleading
comment about swapping implementations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Reorder Server struct fields and assignments to group process identity fields together

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add macOS support for host uptime in support packet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Schumacher
2026-04-21 10:52:10 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 848ceb3c73
commit 58052db8e3
10 changed files with 216 additions and 13 deletions
+3
View File
@@ -120,6 +120,8 @@ type PlatformService struct {
pdpService einterfaces.PolicyDecisionPointInterface
startTime time.Time
// installTypeOverride overrides MM_INSTALL_TYPE in support packet diagnostics.
installTypeOverride string
@@ -150,6 +152,7 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) {
Store: sc.Store,
clusterIFace: sc.Cluster,
hashSeed: maphash.MakeSeed(),
startTime: time.Now(),
goroutineExitSignal: make(chan struct{}, 1),
goroutineBuffered: make(chan struct{}, runtime.NumCPU()),
WebSocketRouter: &WebSocketRouter{
@@ -107,6 +107,11 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "error while getting hostname"))
}
d.Server.ProcessID = os.Getpid()
d.Server.StartedAt = ps.startTime.UTC()
if hostUptimeSeconds, hostUptimeErr := getHostUptimeSeconds(); hostUptimeErr == nil {
d.Server.HostStartedAt = time.Now().Add(-time.Duration(hostUptimeSeconds) * time.Second).UTC()
}
d.Server.Version = model.CurrentVersion
d.Server.BuildHash = model.BuildHash
d.Server.GoVersion = runtime.Version()
@@ -126,7 +131,6 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
if err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting max file descriptor limit"))
}
d.Server.ProcessID = os.Getpid()
/* Config */
d.Config.Source = ps.DescribeConfig()
@@ -9,6 +9,7 @@ import (
"errors"
"os"
"path"
"runtime"
"testing"
"github.com/goccy/go-yaml"
@@ -213,6 +214,13 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
assert.True(t, d.Server.OpenFileDescriptors == -1 || d.Server.OpenFileDescriptors > 0, "OpenFileDescriptors should be -1 (unsupported) or positive, got %d", d.Server.OpenFileDescriptors)
assert.True(t, d.Server.MaxFileDescriptors == -1 || d.Server.MaxFileDescriptors > 0, "MaxFileDescriptors should be -1 (unsupported) or positive, got %d", d.Server.MaxFileDescriptors)
assert.Positive(t, d.Server.ProcessID)
assert.False(t, d.Server.StartedAt.IsZero())
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
assert.False(t, d.Server.HostStartedAt.IsZero())
assert.True(t, !d.Server.HostStartedAt.After(d.Server.StartedAt))
} else {
assert.True(t, d.Server.HostStartedAt.IsZero(), "HostStartedAt should be zero on unsupported platforms")
}
/* Config */
assert.Equal(t, "memory://", d.Config.Source)
@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"fmt"
"time"
"golang.org/x/sys/unix"
)
// getHostUptimeSeconds uses kern.boottime sysctl to return the number of seconds
// the host OS has been running.
func getHostUptimeSeconds() (int64, error) {
tv, err := unix.SysctlTimeval("kern.boottime")
if err != nil {
return 0, fmt.Errorf("failed to get kern.boottime: %w", err)
}
bootTime := time.Unix(tv.Sec, int64(tv.Usec)*int64(time.Microsecond))
return int64(time.Since(bootTime).Seconds()), nil
}
@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
t.Run("returns a positive value from kern.boottime", func(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.NoError(t, err)
assert.Positive(t, seconds)
})
}
@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"fmt"
"os"
"strconv"
"strings"
)
// getHostUptimeSeconds reads /proc/uptime and returns the number of seconds
// the host OS has been running.
func getHostUptimeSeconds() (int64, error) {
return parseUptimeFile("/proc/uptime")
}
// parseUptimeFile reads an uptime file in the /proc/uptime format and returns
// the number of seconds as an int64. It is a separate function to allow unit
// tests to supply a synthetic file path.
func parseUptimeFile(path string) (int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("failed to read /proc/uptime: %w", err)
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0, fmt.Errorf("unexpected /proc/uptime format")
}
f, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, fmt.Errorf("failed to parse /proc/uptime value: %w", err)
}
return int64(f), nil
}
@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
t.Run("returns a positive value from /proc/uptime", func(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.NoError(t, err)
assert.Positive(t, seconds)
})
t.Run("error on unreadable file", func(t *testing.T) {
missingPath := t.TempDir() + "/uptime"
_, err := parseUptimeFile(missingPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read")
})
t.Run("error on empty file", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
require.NoError(t, f.Close())
_, err = parseUptimeFile(f.Name())
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected /proc/uptime format")
})
t.Run("error on non-numeric first field", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
_, err = f.WriteString("notanumber 0.00\n")
require.NoError(t, err)
require.NoError(t, f.Close())
_, err = parseUptimeFile(f.Name())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse /proc/uptime value")
})
t.Run("parses valid uptime correctly", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
_, err = f.WriteString("12345.67 890.12\n")
require.NoError(t, err)
require.NoError(t, f.Close())
seconds, err := parseUptimeFile(f.Name())
require.NoError(t, err)
assert.Equal(t, int64(12345), seconds)
})
}
@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import "errors"
// ErrHostUptimeUnsupportedPlatform is returned when host uptime detection is not supported on the current platform.
var ErrHostUptimeUnsupportedPlatform = errors.New("host uptime detection not supported on this platform")
// getHostUptimeSeconds returns an error on non-Linux platforms.
func getHostUptimeSeconds() (int64, error) {
return 0, ErrHostUptimeUnsupportedPlatform
}
@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.Error(t, err)
assert.ErrorIs(t, err, ErrHostUptimeUnsupportedPlatform)
assert.Equal(t, int64(0), seconds)
}
+15 -12
View File
@@ -6,6 +6,7 @@ package model
import (
"encoding/json"
"io"
"time"
)
const (
@@ -25,18 +26,20 @@ type SupportPacketDiagnostics struct {
} `yaml:"license"`
Server struct {
OS string `yaml:"os"`
Architecture string `yaml:"architecture"`
CPUCores int `yaml:"cpu_cores"`
TotalMemoryMB uint64 `yaml:"total_memory_mb"`
OpenFileDescriptors int64 `yaml:"open_file_descriptors"`
MaxFileDescriptors int64 `yaml:"max_file_descriptors"`
Hostname string `yaml:"hostname"`
ProcessID int `yaml:"process_id"`
Version string `yaml:"version"`
BuildHash string `yaml:"build_hash"`
GoVersion string `yaml:"go_version"`
InstallationType string `yaml:"installation_type"`
OS string `yaml:"os"`
Architecture string `yaml:"architecture"`
CPUCores int `yaml:"cpu_cores"`
TotalMemoryMB uint64 `yaml:"total_memory_mb"`
OpenFileDescriptors int64 `yaml:"open_file_descriptors"`
MaxFileDescriptors int64 `yaml:"max_file_descriptors"`
Hostname string `yaml:"hostname"`
ProcessID int `yaml:"process_id"`
StartedAt time.Time `yaml:"started_at"`
HostStartedAt time.Time `yaml:"host_started_at,omitempty"`
Version string `yaml:"version"`
BuildHash string `yaml:"build_hash"`
GoVersion string `yaml:"go_version"`
InstallationType string `yaml:"installation_type"`
} `yaml:"server"`
Config struct {