diff --git a/src/go/mongolib/proto/cmdlineopts.go b/src/go/mongolib/proto/cmdlineopts.go
index e3f314b73..1e731ecb8 100644
--- a/src/go/mongolib/proto/cmdlineopts.go
+++ b/src/go/mongolib/proto/cmdlineopts.go
@@ -25,24 +25,39 @@ type Sharding struct {
ClusterRole string `bson:"clusterRole"`
}
+type CloStorageWT struct {
+ EngineConfig struct {
+ CacheSizeGB float64 `bson:"cacheSizeGB"`
+ } `bson:"engineConfig"`
+}
+
type CloStorage struct {
- DbPath string `bson:"dbPath"`
- Engine string `bson:"engine"`
+ DbPath string `bson:"dbPath"`
+ Engine string `bson:"engine"`
+ WiredTiger CloStorageWT `bson:"wiredTiger"`
}
type CloSystemLog struct {
Destination string `bson:"destination"`
Path string `bson:"path"`
+ LogAppend bool `bson:"logAppend"`
+}
+
+type OperationProfiling struct {
+ Mode string `bson:"mode"`
+ SlowOpThresholdMs int64 `bson:"slowOpThresholdMs"`
}
type Parsed struct {
- Sharding Sharding `bson:"sharding"`
- Storage CloStorage `bson:"storage"`
- SystemLog CloSystemLog `bson:"systemLog"`
- Net Net `bson:"net"`
- ProcessManagement ProcessManagement `bson:"processManagement"`
- Replication Replication `bson:"replication"`
- Security Security `bson:"security"`
+ Config string `bson:"config"`
+ Sharding Sharding `bson:"sharding"`
+ Storage CloStorage `bson:"storage"`
+ SystemLog CloSystemLog `bson:"systemLog"`
+ Net Net `bson:"net"`
+ ProcessManagement ProcessManagement `bson:"processManagement"`
+ Replication Replication `bson:"replication"`
+ Security Security `bson:"security"`
+ OperationProfiling OperationProfiling `bson:"operationProfiling"`
}
// Security is a struct to hold security related configs
diff --git a/src/go/mongolib/proto/server_status.go b/src/go/mongolib/proto/server_status.go
index 375c0e08b..98033eeda 100644
--- a/src/go/mongolib/proto/server_status.go
+++ b/src/go/mongolib/proto/server_status.go
@@ -40,6 +40,23 @@ type ServerStatus struct {
ShardCursorType map[string]interface{} `bson:"shardCursorType"`
StorageEngine StorageEngine `bson:"storageEngine"`
WiredTiger *WiredTiger `bson:"wiredTiger"`
+ Tcmalloc *TcmallocStats `bson:"tcmalloc"`
+}
+
+type TcmallocStats struct {
+ Generic struct {
+ CurrentAllocatedBytes int64 `bson:"current_allocated_bytes"`
+ HeapSize int64 `bson:"heap_size"`
+ } `bson:"generic"`
+ Tcmalloc struct {
+ PageheapFreeBytes int64 `bson:"pageheap_free_bytes"`
+ PageheapUnmappedBytes int64 `bson:"pageheap_unmapped_bytes"`
+ MaxTotalThreadCacheBytes int64 `bson:"max_total_thread_cache_bytes"`
+ CurrentTotalThreadCacheBytes int64 `bson:"current_total_thread_cache_bytes"`
+ CentralCacheFreeBytes int64 `bson:"central_cache_free_bytes"`
+ TransferCacheFreeBytes int64 `bson:"transfer_cache_free_bytes"`
+ ThreadCacheFreeBytes int64 `bson:"thread_cache_free_bytes"`
+ } `bson:"tcmalloc"`
}
type StorageEngine struct {
@@ -67,9 +84,13 @@ type ConcurrentTransStats struct {
// CacheStats stores cache statistics for WiredTiger.
type CacheStats struct {
- TrackedDirtyBytes int64 `bson:"tracked dirty bytes in the cache"`
- CurrentCachedBytes int64 `bson:"bytes currently in the cache"`
- MaxBytesConfigured int64 `bson:"maximum bytes configured"`
+ TrackedDirtyBytes int64 `bson:"tracked dirty bytes in the cache"`
+ CurrentCachedBytes int64 `bson:"bytes currently in the cache"`
+ MaxBytesConfigured int64 `bson:"maximum bytes configured"`
+ PagesEvictedUnmodified int64 `bson:"unmodified pages evicted"`
+ PagesEvictedModified int64 `bson:"modified pages evicted"`
+ PagesReadIntoCache int64 `bson:"pages read into cache"`
+ PagesWrittenFromCache int64 `bson:"pages written from cache"`
}
// TransactionStats stores transaction checkpoints in WiredTiger.
diff --git a/src/go/pt-mongodb-summary/CLAUDE.md b/src/go/pt-mongodb-summary/CLAUDE.md
new file mode 100644
index 000000000..b036e28d9
--- /dev/null
+++ b/src/go/pt-mongodb-summary/CLAUDE.md
@@ -0,0 +1,76 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Commands
+
+All make commands run from `/src/go/` (the Go module root), not the tool directory itself.
+
+**Build:**
+```bash
+make build # Build for current platform → ../../bin/pt-mongodb-summary
+make linux-amd64 # Cross-compile for Linux x86-64
+make darwin-arm64 # Cross-compile for macOS Apple Silicon
+VERSION=3.7.1 make linux-amd64 # Build with version embedded via LDFLAGS
+```
+
+**Test:**
+```bash
+make test # Runs ./runtests.sh (requires Docker for MongoDB containers)
+make env-up # Start MongoDB test containers (standalone + sharded cluster)
+make env-down # Stop test containers
+go test -v ./... # Run tests directly (from tool directory)
+go test -v -run TestName ./... # Run a specific test
+```
+
+**Lint & Format:**
+```bash
+make style # Check formatting with gofmt
+make vet # Run go vet
+make format # Auto-format with gofumpt and gofumports
+```
+
+## Architecture
+
+`pt-mongodb-summary` connects to a MongoDB instance (standalone, replica set, or sharded cluster), collects diagnostic data via admin commands, and renders a human-readable or JSON report.
+
+### Data flow
+
+```
+main() → connect → collect → format → output
+```
+
+1. **Connect** (`getClientOptions`): Builds `*options.ClientOptions` from CLI flags or URI, handling TLS and auth.
+2. **Collect**: Each `get*` function runs MongoDB admin commands and returns a typed struct:
+ - `getHostInfo` → `hostInfo` (hostInfo, serverStatus, cmdLineOpts commands)
+ - `getMongosInfo` → `mongosInfo` (queries `config.mongos` collection)
+ - `getSecuritySettings` → `security` (auth/SSL from serverStatus + usersInfo + rolesInfo)
+ - `getOpCountersStats` → `opCounters` (samples serverStatus N times)
+ - `oplog.GetOplogInfo` → `[]proto.OplogInfo` (reads local.oplog.rs)
+ - `getClusterwideInfo` → `clusterwideInfo` (listDatabases, collStats, sh.status; mongos only)
+ - `GetBalancerStats` → `proto.BalancerStats` (parses config.changelog; mongos only)
+3. **Aggregate** into `collectedInfo` struct.
+4. **Format** (`formatResults`): Either marshal to JSON or render via Go templates in `templates/`.
+
+### Templates
+
+Each report section has a dedicated template file under `templates/`. Templates receive the full `collectedInfo` struct. Adding a new output section means adding a template file and calling it from `formatResults`.
+
+### Shared packages
+
+- `../../mongolib/proto/` — shared MongoDB data types (e.g., `Members`, `OplogInfo`, `BalancerStats`)
+- `../../mongolib/util/` — MongoDB helpers (e.g., `GetReplicasetMembers`)
+- `../../lib/config/` — config file loading
+- `../../lib/versioncheck/` — online version check against Percona's API
+- `../../internal/testutils/` — test helpers (connection setup, fixtures)
+
+### Test fixtures
+
+Sample MongoDB command responses live in `test/sample/*.json`. Tests in `main_test.go` unmarshal these fixtures to test parsing logic without a live MongoDB connection. Integration tests (via `runtests.sh`) require the Docker cluster from `make env-up`.
+
+## Key conventions
+
+- CLI parsing uses `github.com/alecthomas/kong` via `config.Setup()`. New options are added as fields with kong struct tags in `cliOptions`. Validation and side-effects (log level, version check, password prompt, positional host:port) go in `AfterApply()`. Config files (`~/.pt-mongodb-summary.conf`, `/etc/percona-toolkit/pt-mongodb-summary.conf`) are automatically supported.
+- Logging is via `github.com/sirupsen/logrus`; level is set from `--log-level` flag.
+- MongoDB connection URI takes precedence over individual host/port/auth flags.
+- The binary embeds `Version`, `Build`, `GoVersion`, and `Commit` via LDFLAGS at build time.
diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go
index 020356521..7b0507665 100644
--- a/src/go/pt-mongodb-summary/main.go
+++ b/src/go/pt-mongodb-summary/main.go
@@ -21,18 +21,18 @@ import (
"crypto/x509"
"encoding/json"
"fmt"
- "html/template"
+ "text/template"
"io/ioutil"
"net"
"os"
"os/user"
"path/filepath"
+ "runtime"
"slices"
"strings"
"time"
version "github.com/hashicorp/go-version"
- "github.com/pborman/getopt"
"github.com/pkg/errors"
"github.com/shirou/gopsutil/process"
log "github.com/sirupsen/logrus"
@@ -82,6 +82,19 @@ var (
directConnection = true
)
+var ptdebug = os.Getenv("PTDEBUG") != ""
+
+func ptDebugf(format string, args ...interface{}) {
+ if !ptdebug {
+ return
+ }
+ _, file, line, _ := runtime.Caller(1)
+ fmt.Fprintf(os.Stderr, "# %s:%d %d %s %s\n",
+ filepath.Base(file), line, os.Getpid(),
+ time.Now().Format("2006-01-02T15:04:05.000Z07:00"),
+ fmt.Sprintf(format, args...))
+}
+
type TimedStats struct {
Min int64
Max int64
@@ -112,12 +125,14 @@ type hostInfo struct {
ProcCreateTime time.Time
ProcProcessCount int
- CmdlineArgs []string
+ CmdlineArgs []string
+ ParsedConfig *proto.CommandLineOptions
// Server Status
ProcessName string
ReplicasetName string
Version string
NodeType string
+ FCV string
}
type procInfo struct {
@@ -187,22 +202,77 @@ func (t mongosInfo) MaxNameLen() int {
}
type cliOptions struct {
- Host string
- Port string
- User string
- Password string
- AuthDB string
- LogLevel string
- OutputFormat string
- SSLCAFile string
- SSLPEMKeyFile string
- RunningOpsSamples int
- RunningOpsInterval int
- URI string
- Help bool
- Version bool
- NoVersionCheck bool
- NoRunningOps bool
+ config.ConfigFlag
+ config.VersionFlag
+ config.VersionCheckFlag
+
+ URI string `name:"uri" help:"MongoDB connection URI. Cannot be combined with --host or --port."`
+ Host string `name:"host" help:"Host to connect to" default:""`
+ Port string `name:"port" help:"Port to connect to" default:""`
+ User string `name:"username" short:"u" help:"Username for MongoDB authentication" default:""`
+ Password config.StdinRequestString `name:"password" short:"p" help:"Password for MongoDB authentication. Omit value to prompt." optional:""`
+ AuthDB string `name:"authenticationDatabase" short:"a" help:"Authentication database" default:"admin"`
+ LogLevel string `name:"log-level" short:"l" help:"Log level: panic, fatal, error, warn, info, debug" default:"warn"`
+ OutputFormat string `name:"output-format" short:"f" help:"Output format: text or json" default:"text" enum:"text,json"`
+ SSLCAFile string `name:"sslCAFile" help:"SSL CA cert file used for authentication" default:""`
+ SSLPEMKeyFile string `name:"sslPEMKeyFile" help:"SSL client PEM file used for authentication" default:""`
+ RunningOpsSamples int `name:"running-ops-samples" short:"s" help:"Number of samples to collect for running ops" default:"5"`
+ RunningOpsInterval int `name:"running-ops-interval" short:"i" help:"Interval between running ops samples in milliseconds" default:"1000"`
+
+ HostPort string `arg:"" optional:"" name:"host[:port]" help:"Host and optional port (legacy positional syntax)" default:""`
+}
+
+func (o *cliOptions) AfterApply() error {
+ if o.Version {
+ fmt.Println(toolname)
+ fmt.Printf("Version %s\n", Version)
+ fmt.Printf("Build: %s using %s\n", Build, GoVersion)
+ fmt.Printf("Commit: %s\n", Commit)
+ return nil
+ }
+
+ ll, err := log.ParseLevel(o.LogLevel)
+ if err != nil {
+ return fmt.Errorf("cannot set log level: %w", err)
+ }
+ log.SetLevel(ll)
+
+ if o.VersionCheck {
+ advice, err := versioncheck.CheckUpdates(toolname, Version)
+ if err != nil {
+ log.Infof("cannot check version updates: %s", err)
+ } else if advice != "" {
+ log.Infof("%s", advice)
+ }
+ }
+
+ if err := o.Password.Request(func() (string, error) {
+ print("Password: ")
+ pass, err := term.ReadPassword(0)
+ return string(pass), err
+ }); err != nil {
+ return err
+ }
+
+ if o.HostPort != "" {
+ if o.URI != "" || o.Host != "" || o.Port != "" {
+ return fmt.Errorf("host[:port] positional arg cannot be combined with --uri, --host, or --port")
+ }
+ host, port, splitErr := net.SplitHostPort(o.HostPort)
+ if splitErr != nil {
+ o.Host = o.HostPort
+ } else {
+ o.Host = host
+ o.Port = port
+ }
+ o.HostPort = ""
+ }
+
+ if o.URI != "" && (o.Host != "" || o.Port != "") {
+ return fmt.Errorf("if a full URI is provided, you cannot also specify --host or --port")
+ }
+
+ return nil
}
type collectedInfo struct {
@@ -214,45 +284,99 @@ type collectedInfo struct {
SecuritySettings *security
HostInfo *hostInfo
MongosInfo *mongosInfo
+ OSChecks *osProductionChecks
+ DBInventory []dbInventoryEntry
+ StatusDelta *statusDelta
+ WiredTiger *wiredTigerInfo
+ ShardsInfo *proto.ShardsInfo
+ Connections *connectionInfo
+ ChangeStream *changeStreamInfo
Errors []string
}
-func main() {
- opts, err := parseFlags()
- if err != nil {
- log.Errorf("cannot get parameters: %s", err.Error())
+type connectionInfo struct {
+ Current int64
+ Available int64
+ TotalCreated int64
+ MaxIncoming int
+ SlowOpThresholdMs int64
+ ProfilingMode string
+}
- os.Exit(cannotParseCommandLineParameters)
- }
+type changeStreamInfo struct {
+ PreAndPostImagesExpire string
+}
- if opts == nil && err == nil {
- return
- }
+type osProductionChecks struct {
+ KernelVersion string
+ MemSizeMB float64
+ NumCores float64
+ NUMAEnabled bool
+ OpenFilesLimit float64
+ OpenFilesWarn bool
+ VMMaxMapCount int64
+ VMMaxMapWarn bool
+ TasksMax string
+}
- logLevel, err := log.ParseLevel(opts.LogLevel)
- if err != nil {
- fmt.Printf("cannot set log level: %s", err.Error())
- }
+type oplogDisplay struct {
+ Nodes []proto.OplogInfo
+ MinHost string
+ MaxHost string
+}
- log.SetLevel(logLevel)
+type dbInventoryEntry struct {
+ Name string
+ SizeScaled float64
+ SizeUnit string
+ Collections int64
+ Indexes int64
+ TTLIndexes int64
+}
- if opts.Version {
- fmt.Println(toolname)
- fmt.Printf("Version %s\n", Version)
- fmt.Printf("Build: %s using %s\n", Build, GoVersion)
- fmt.Printf("Commit: %s\n", Commit)
+type counterDelta struct {
+ PerSec float64
+ PerDay float64
+}
- return
+type statusDelta struct {
+ Insert counterDelta
+ Query counterDelta
+ Update counterDelta
+ Delete counterDelta
+ GetMore counterDelta
+ Command counterDelta
+ Duration time.Duration
+}
+
+type wiredTigerInfo struct {
+ CacheUsedMB float64
+ CacheMaxMB float64
+ CacheUsedPct float64
+ DirtyMB float64
+ DirtyPct float64
+ PagesEvictedUnmodified int64
+ PagesEvictedModified int64
+ PagesReadIntoCache int64
+ PagesWrittenFromCache int64
+ // tcmalloc
+ TcmallocAvailable bool
+ TcmallocAllocMB float64
+ TcmallocHeapMB float64
+ TcmallocPageheapFreeMB float64
+ TcmallocPageheapUnmappedMB float64
+ TcmallocThreadCacheFreeMB float64
+}
+
+func main() {
+ opts := &cliOptions{}
+ if _, _, err := config.Setup(toolname, opts); err != nil {
+ log.Errorf("cannot get parameters: %s", err)
+ os.Exit(cannotParseCommandLineParameters)
}
- conf := config.DefaultConfig(toolname)
- if !conf.GetBool("no-version-check") && !opts.NoVersionCheck {
- advice, err := versioncheck.CheckUpdates(toolname, Version)
- if err != nil {
- log.Infof("cannot check version updates: %s", err.Error())
- } else if advice != "" {
- log.Infof("%s", advice)
- }
+ if opts.Version {
+ return
}
ctx := context.Background()
@@ -271,9 +395,14 @@ func main() {
}
if err := client.Connect(ctx); err != nil {
- log.Errorf("Cannot connect to MongoDB: %s", err)
+ if strings.Contains(err.Error(), "EOF") || strings.Contains(err.Error(), "tls:") {
+ log.Errorf("Cannot connect to MongoDB (possible TLS mismatch or wrong port): %s", err)
+ } else {
+ log.Errorf("Cannot connect to MongoDB: %s", err)
+ }
os.Exit(cannotConnectToMongoDB)
}
+ ptDebugf("connected to MongoDB at %v", clientOptions.Hosts)
defer client.Disconnect(ctx) // nolint
@@ -281,27 +410,34 @@ func main() {
if err != nil && errors.Is(err, util.ShardingNotEnabledError) {
log.Errorf("Cannot get hostnames: %s", err)
}
+ ptDebugf("got hostnames: %v", hostnames)
ci := &collectedInfo{}
+ ptDebugf("collecting mongos info")
ci.MongosInfo, err = getMongosInfo(ctx, client)
if err != nil {
log.Warnf("[Warning] cannot get mongos info: %v\n", err)
}
-
+ ptDebugf("collecting host info")
ci.HostInfo, err = getHostInfo(ctx, client)
if err != nil {
log.Errorf("Cannot get host info for %q: %s", clientOptions.Hosts, err)
os.Exit(cannotGetHostInfo) //nolint:gocritic
}
+ ptDebugf("host info: version=%s nodeType=%s", ci.HostInfo.Version, ci.HostInfo.NodeType)
if ci.ReplicaMembers, err = util.GetReplicasetMembers(ctx, clientOptions); err != nil {
log.Warnf("[Warning] cannot get replicaset members: %v\n", err)
}
+ ptDebugf("got %d replica members", len(ci.ReplicaMembers))
log.Debugf("replicaMembers:\n%+v\n", ci.ReplicaMembers)
- if opts.RunningOpsSamples > 0 && opts.RunningOpsInterval > 0 {
+ isArbiter := ci.HostInfo != nil && ci.HostInfo.NodeType == "arbiter"
+
+ if !isArbiter && opts.RunningOpsSamples > 0 && opts.RunningOpsInterval > 0 {
+ ptDebugf("collecting op counters (%d samples, %dms interval)", opts.RunningOpsSamples, opts.RunningOpsInterval)
ci.RunningOps, err = getOpCountersStats(
ctx, client, opts.RunningOpsSamples,
time.Duration(opts.RunningOpsInterval)*time.Millisecond,
@@ -309,9 +445,11 @@ func main() {
if err != nil {
log.Printf("[Error] cannot get Opcounters stats: %v\n", err)
}
+ ptDebugf("op counters done")
}
if ci.HostInfo != nil {
+ ptDebugf("collecting security settings")
if ci.SecuritySettings, err = getSecuritySettings(ctx, client, ci.HostInfo.Version); err != nil {
log.Errorf("[Error] cannot get security settings: %v\n", err)
}
@@ -319,29 +457,79 @@ func main() {
log.Warn("Cannot check security settings since host info is not available (permissions?)")
}
- if ci.OplogInfo, err = oplog.GetOplogInfo(ctx, hostnames, clientOptions); err != nil {
- log.Infof("Cannot get Oplog info: %s\n", err)
- } else {
- if len(ci.OplogInfo) == 0 {
+ ptDebugf("collecting FCV")
+ ci.HostInfo.FCV = getFCV(ctx, client)
+ ptDebugf("FCV: %s", ci.HostInfo.FCV)
+
+ ptDebugf("collecting OS production checks")
+ ci.OSChecks, err = getOSProductionChecks(ctx, client)
+ if err != nil {
+ log.Warnf("[Warning] cannot get OS production checks: %v\n", err)
+ }
+
+ ptDebugf("collecting connection info")
+ ci.Connections, err = getConnectionInfo(ctx, client)
+ if err != nil {
+ log.Warnf("[Warning] cannot get connection info: %v\n", err)
+ }
+
+ ptDebugf("collecting change stream info")
+ ci.ChangeStream, err = getChangeStreamInfo(ctx, client)
+ if err != nil {
+ log.Debugf("change stream info not available: %v", err)
+ }
+
+ ptDebugf("collecting database inventory")
+ ci.DBInventory, err = getDatabaseInventory(ctx, client)
+ if err != nil {
+ log.Warnf("[Warning] cannot get database inventory: %v\n", err)
+ }
+ ptDebugf("database inventory: %d entries", len(ci.DBInventory))
+
+ if !isArbiter {
+ ptDebugf("collecting oplog info")
+ if ci.OplogInfo, err = oplog.GetOplogInfo(ctx, hostnames, clientOptions); err != nil {
+ log.Infof("Cannot get Oplog info: %s\n", err)
+ } else if len(ci.OplogInfo) == 0 {
log.Info("oplog info is empty. Skipping")
- } else {
- ci.OplogInfo = ci.OplogInfo[:1]
+ }
+ ptDebugf("oplog info: %d entries", len(ci.OplogInfo))
+
+ ptDebugf("collecting status delta (10s window)")
+ ci.StatusDelta, err = getStatusDelta(ctx, client, 10*time.Second)
+ if err != nil {
+ log.Warnf("[Warning] cannot get status delta: %v\n", err)
+ }
+ ptDebugf("status delta done")
+
+ ptDebugf("collecting WiredTiger info")
+ ci.WiredTiger, err = getWiredTigerInfo(ctx, client)
+ if err != nil {
+ log.Debugf("WiredTiger metrics not available: %v", err)
}
}
// individual servers won't know about this info
if ci.HostInfo.NodeType == typeMongos {
+ ptDebugf("collecting cluster-wide info (mongos)")
if ci.ClusterWideInfo, err = getClusterwideInfo(ctx, client); err != nil {
log.Printf("[Error] cannot get cluster wide info: %v\n", err)
}
+
+ ptDebugf("collecting shards info")
+ if ci.ShardsInfo, err = getShardsInfo(ctx, client); err != nil {
+ log.Warnf("[Warning] cannot get shards info: %v\n", err)
+ }
}
if ci.HostInfo.NodeType == typeMongos {
+ ptDebugf("collecting balancer stats")
if ci.BalancerStats, err = GetBalancerStats(ctx, client); err != nil {
log.Printf("[Error] cannot get balancer stats: %v\n", err)
}
}
+ ptDebugf("formatting results (format=%s)", opts.OutputFormat)
out, err := formatResults(ci, opts.OutputFormat)
if err != nil {
log.Errorf("Cannot format the results: %s", err)
@@ -365,43 +553,68 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) {
default:
buf = new(bytes.Buffer)
- t := template.Must(template.New("mongos").Parse(templates.MongosInfo))
- if err := t.Execute(buf, ci.MongosInfo); err != nil {
- return nil, errors.Wrap(err, "cannot parse mongos section of the output template")
- }
-
- t = template.Must(template.New("replicas").Parse(templates.Replicas))
- if err := t.Execute(buf, ci.ReplicaMembers); err != nil {
- return nil, errors.Wrap(err, "cannot parse replicas section of the output template")
- }
-
- t = template.Must(template.New("hosttemplateData").Parse(templates.HostInfo))
+ // 1. Host & OS
+ t := template.Must(template.New("hosttemplateData").Parse(templates.HostInfo))
if err := t.Execute(buf, ci.HostInfo); err != nil {
return nil, errors.Wrap(err, "cannot parse hosttemplateData section of the output template")
}
+ t = template.Must(template.New("oschecks").Parse(templates.OSChecks))
+ if err := t.Execute(buf, ci.OSChecks); err != nil {
+ return nil, errors.Wrap(err, "cannot parse oschecks section of the output template")
+ }
+
+ // 2. Configuration
t = template.Must(template.New("cmdlineargsa").Parse(templates.CmdlineArgs))
if err := t.Execute(buf, ci.HostInfo); err != nil {
return nil, errors.Wrap(err, "cannot parse the command line args section of the output template")
}
- t = template.Must(template.New("runningOps").Parse(templates.RunningOps))
- if err := t.Execute(buf, ci.RunningOps); err != nil {
- return nil, errors.Wrap(err, "cannot parse runningOps section of the output template")
+ t = template.Must(template.New("connections").Parse(templates.Connections))
+ if err := t.Execute(buf, ci.Connections); err != nil {
+ return nil, errors.Wrap(err, "cannot parse connections section of the output template")
}
+ t = template.Must(template.New("changestream").Parse(templates.ChangeStream))
+ if err := t.Execute(buf, ci.ChangeStream); err != nil {
+ return nil, errors.Wrap(err, "cannot parse changestream section of the output template")
+ }
+
+ // 3. Security
t = template.Must(template.New("ssl").Parse(templates.Security))
if err := t.Execute(buf, ci.SecuritySettings); err != nil {
return nil, errors.Wrap(err, "cannot parse ssl section of the output template")
}
- if ci.OplogInfo != nil && len(ci.OplogInfo) > 0 {
+ // 4. Replication
+ t = template.Must(template.New("replicas").Parse(templates.Replicas))
+ if err := t.Execute(buf, ci.ReplicaMembers); err != nil {
+ return nil, errors.Wrap(err, "cannot parse replicas section of the output template")
+ }
+
+ if len(ci.OplogInfo) > 0 {
+ od := oplogDisplay{
+ Nodes: ci.OplogInfo,
+ MinHost: ci.OplogInfo[0].Hostname,
+ MaxHost: ci.OplogInfo[len(ci.OplogInfo)-1].Hostname,
+ }
t = template.Must(template.New("oplogInfo").Parse(templates.Oplog))
- if err := t.Execute(buf, ci.OplogInfo[0]); err != nil {
+ if err := t.Execute(buf, od); err != nil {
return nil, errors.Wrap(err, "cannot parse oplogInfo section of the output template")
}
}
+ // 5. Sharding
+ t = template.Must(template.New("mongos").Parse(templates.MongosInfo))
+ if err := t.Execute(buf, ci.MongosInfo); err != nil {
+ return nil, errors.Wrap(err, "cannot parse mongos section of the output template")
+ }
+
+ t = template.Must(template.New("shardsinfo").Parse(templates.ShardsInfo))
+ if err := t.Execute(buf, ci.ShardsInfo); err != nil {
+ return nil, errors.Wrap(err, "cannot parse shardsinfo section of the output template")
+ }
+
t = template.Must(template.New("clusterwide").Parse(templates.Clusterwide))
if err := t.Execute(buf, ci.ClusterWideInfo); err != nil {
return nil, errors.Wrap(err, "cannot parse clusterwide section of the output template")
@@ -411,6 +624,28 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) {
if err := t.Execute(buf, ci.BalancerStats); err != nil {
return nil, errors.Wrap(err, "cannot parse balancer section of the output template")
}
+
+ // 6. Database Inventory
+ t = template.Must(template.New("dbinventory").Parse(templates.DBInventory))
+ if err := t.Execute(buf, ci.DBInventory); err != nil {
+ return nil, errors.Wrap(err, "cannot parse dbinventory section of the output template")
+ }
+
+ // 7. Performance & Storage
+ t = template.Must(template.New("runningOps").Parse(templates.RunningOps))
+ if err := t.Execute(buf, ci.RunningOps); err != nil {
+ return nil, errors.Wrap(err, "cannot parse runningOps section of the output template")
+ }
+
+ t = template.Must(template.New("statusdelta").Parse(templates.StatusDelta))
+ if err := t.Execute(buf, ci.StatusDelta); err != nil {
+ return nil, errors.Wrap(err, "cannot parse statusdelta section of the output template")
+ }
+
+ t = template.Must(template.New("wiredtiger").Parse(templates.WiredTiger))
+ if err := t.Execute(buf, ci.WiredTiger); err != nil {
+ return nil, errors.Wrap(err, "cannot parse wiredtiger section of the output template")
+ }
}
return buf.Bytes(), nil
@@ -446,6 +681,7 @@ func getHostInfo(ctx context.Context, client *mongo.Client) (*hostInfo, error) {
if cmdOpts.Parsed.Storage.DbPath != "" {
i.DBPath = cmdOpts.Parsed.Storage.DbPath
}
+ i.ParsedConfig = &cmdOpts
}
var ss proto.ServerStatus
@@ -456,16 +692,16 @@ func getHostInfo(ctx context.Context, client *mongo.Client) (*hostInfo, error) {
i.Version = ss.Version
if ss.Repl != nil {
i.ReplicasetName = ss.Repl.SetName
+ if fmt.Sprintf("%v", ss.Repl.ArbiterOnly) == "true" {
+ i.NodeType = "arbiter"
+ }
}
pi := procInfo{}
- if err := getProcInfo(int32(ss.Pid), &pi); err != nil {
- pi.Error = err
- } else {
- i.ProcPath = pi.Path
- i.ProcUserName = pi.UserName
- i.ProcCreateTime = pi.CreateTime
- }
+ getProcInfo(int32(ss.Pid), &pi)
+ i.ProcPath = pi.Path
+ i.ProcUserName = pi.UserName
+ i.ProcCreateTime = pi.CreateTime
}
return i, nil
@@ -474,7 +710,8 @@ func getHostInfo(ctx context.Context, client *mongo.Client) (*hostInfo, error) {
func countMongodProcesses() (int, error) {
pids, err := process.Pids()
if err != nil {
- return 0, err
+ log.Debugf("cannot list processes (restricted environment): %s", err)
+ return 0, nil
}
count := 0
@@ -832,27 +1069,20 @@ func getOpCountersStats(ctx context.Context, client *mongo.Client, count int,
return oc, nil
}
-func getProcInfo(pid int32, templateData *procInfo) error {
- // proc, err := process.NewProcess(templateData.ServerStatus.Pid)
+func getProcInfo(pid int32, pi *procInfo) error {
proc, err := process.NewProcess(pid)
if err != nil {
- return errors.New(fmt.Sprintf("cannot get process %d", pid))
+ log.Debugf("cannot get process %d (restricted environment): %s", pid, err)
+ return nil
}
-
- ct, err := proc.CreateTime()
- if err != nil {
- return err
+ if ct, err := proc.CreateTime(); err == nil {
+ pi.CreateTime = time.Unix(ct/1000, 0)
}
-
- templateData.CreateTime = time.Unix(ct/1000, 0)
- templateData.Path, err = proc.Exe()
- if err != nil {
- return err
+ if path, err := proc.Exe(); err == nil {
+ pi.Path = path
}
-
- templateData.UserName, err = proc.Username()
- if err != nil {
- return err
+ if name, err := proc.Username(); err == nil {
+ pi.UserName = name
}
return nil
}
@@ -979,88 +1209,232 @@ func externalIP() (string, error) {
return "", errors.New("are you connected to the network?")
}
-func parseFlags() (*cliOptions, error) {
- opts := &cliOptions{
- LogLevel: DefaultLogLevel,
- RunningOpsSamples: DefaultRunningOpsSamples,
- RunningOpsInterval: DefaultRunningOpsInterval, // milliseconds
- AuthDB: DefaultAuthDB,
- OutputFormat: DefaultOutputFormat,
- }
-
- gop := getopt.New()
- gop.BoolVarLong(&opts.Help, "help", 'h', "Show help")
- gop.BoolVarLong(&opts.Version, "version", 'v', "", "Show version & exit")
- gop.BoolVarLong(&opts.NoVersionCheck, "no-version-check", 'c', "", "Default: Don't check for updates")
-
- gop.StringVarLong(&opts.URI, "uri", 0, `URI describes the hosts to be used and options. Flags has higher priority. If a full URI is provided, you cannot also specify "--host" or "--port".`)
- gop.StringVarLong(&opts.Host, "host", 0, "Host")
- gop.StringVarLong(&opts.Port, "port", 0, "Port")
-
- gop.StringVarLong(&opts.User, "username", 'u', "", "Username to use for optional MongoDB authentication")
- gop.StringVarLong(&opts.Password, "password", 'p', "", "Password to use for optional MongoDB authentication").
- SetOptional()
- gop.StringVarLong(&opts.AuthDB, "authenticationDatabase", 'a', "admin",
- "Database to use for optional MongoDB authentication. Default: admin")
- gop.StringVarLong(&opts.LogLevel, "log-level", 'l', "error",
- "Log level: panic, fatal, error, warn, info, debug. Default: error")
- gop.StringVarLong(&opts.OutputFormat, "output-format", 'f', "text", "Output format: text, json. Default: text")
-
- gop.IntVarLong(&opts.RunningOpsSamples, "running-ops-samples", 's',
- fmt.Sprintf("Number of samples to collect for running ops. Default: %d", opts.RunningOpsSamples),
- )
-
- gop.IntVarLong(&opts.RunningOpsInterval, "running-ops-interval", 'i',
- fmt.Sprintf("Interval to wait between running ops samples in milliseconds. Default %d milliseconds",
- opts.RunningOpsInterval),
- )
-
- gop.StringVarLong(&opts.SSLCAFile, "sslCAFile", 0, "SSL CA cert file used for authentication")
- gop.StringVarLong(&opts.SSLPEMKeyFile, "sslPEMKeyFile", 0, "SSL client PEM file used for authentication")
-
- gop.SetParameters("host[:port]")
- gop.Parse(os.Args)
-
- if gop.NArgs() > 0 {
- if gop.IsSet("host") || gop.IsSet("port") || gop.IsSet("uri") {
- return nil, fmt.Errorf(`parameter host[:port] is not compatible with "--uri", "--host" and "--port" flags set`)
- }
- var err error
- opts.Host, opts.Port, err = net.SplitHostPort(gop.Arg(0))
- if err != nil {
- return nil, err
+func getOSProductionChecks(ctx context.Context, client *mongo.Client) (*osProductionChecks, error) {
+ hi := proto.HostInfo{}
+ if err := client.Database("admin").RunCommand(ctx, primitive.M{"hostInfo": 1}).Decode(&hi); err != nil {
+ return nil, errors.Wrap(err, "getOSProductionChecks.hostInfo")
+ }
+ c := &osProductionChecks{
+ KernelVersion: hi.Extra.KernelVersion,
+ MemSizeMB: hi.System.MemSizeMB,
+ NumCores: hi.System.NumCores,
+ NUMAEnabled: hi.System.NumaEnabled,
+ OpenFilesLimit: hi.Extra.MaxOpenFiles,
+ OpenFilesWarn: hi.Extra.MaxOpenFiles > 0 && hi.Extra.MaxOpenFiles < 65535,
+ }
+ if data, err := os.ReadFile("/proc/sys/vm/max_map_count"); err == nil {
+ fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &c.VMMaxMapCount)
+ c.VMMaxMapWarn = c.VMMaxMapCount < 262144
+ }
+ if data, err := os.ReadFile("/proc/1/limits"); err == nil {
+ for _, line := range strings.Split(string(data), "\n") {
+ if strings.HasPrefix(line, "Max processes") {
+ fields := strings.Fields(line)
+ if len(fields) >= 4 {
+ c.TasksMax = fields[3]
+ }
+ break
+ }
}
- gop.Parse(gop.Args())
}
+ return c, nil
+}
- if gop.IsSet("password") && opts.Password == "" {
- print("Password: ")
+func getShardsInfo(ctx context.Context, client *mongo.Client) (*proto.ShardsInfo, error) {
+ cursor, err := client.Database("config").Collection("shards").Find(ctx, primitive.M{})
+ if err != nil {
+ return nil, errors.Wrap(err, "getShardsInfo")
+ }
+ defer cursor.Close(ctx)
+ var si proto.ShardsInfo
+ if err := cursor.All(ctx, &si.Shards); err != nil {
+ return nil, errors.Wrap(err, "getShardsInfo.decode")
+ }
+ si.OK = 1
+ return &si, nil
+}
- pass, err := term.ReadPassword(0)
- if err != nil {
- return opts, err
+func getDatabaseInventory(ctx context.Context, client *mongo.Client) ([]dbInventoryEntry, error) {
+ var dbs databases
+ if err := client.Database("admin").RunCommand(ctx, primitive.M{"listDatabases": 1}).Decode(&dbs); err != nil {
+ return nil, errors.Wrap(err, "getDatabaseInventory.listDatabases")
+ }
+ var result []dbInventoryEntry
+ for _, db := range dbs.Databases {
+ var stats struct {
+ Collections int64 `bson:"collections"`
+ Indexes int64 `bson:"indexes"`
+ DataSize float64 `bson:"dataSize"`
}
-
- opts.Password = string(pass)
+ if err := client.Database(db.Name).RunCommand(ctx, primitive.M{"dbStats": 1}).Decode(&stats); err != nil {
+ log.Debugf("cannot get dbStats for %s: %s", db.Name, err)
+ continue
+ }
+ ttl := countTTLIndexes(ctx, client, db.Name)
+ scaled, unit := sizeAndUnit(int64(stats.DataSize))
+ result = append(result, dbInventoryEntry{
+ Name: db.Name,
+ SizeScaled: scaled,
+ SizeUnit: unit,
+ Collections: stats.Collections,
+ Indexes: stats.Indexes,
+ TTLIndexes: ttl,
+ })
}
+ return result, nil
+}
- if gop.IsSet("uri") && (gop.IsSet("host") || gop.IsSet("port")) {
- return nil, fmt.Errorf("If a full URI is provided, you cannot also specify --host or --port")
+func countTTLIndexes(ctx context.Context, client *mongo.Client, dbName string) int64 {
+ colls, err := client.Database(dbName).ListCollectionNames(ctx, primitive.M{})
+ if err != nil {
+ return 0
+ }
+ var count int64
+ for _, coll := range colls {
+ cursor, err := client.Database(dbName).Collection(coll).Indexes().List(ctx)
+ if err != nil {
+ continue
+ }
+ var indexes []struct {
+ ExpireAfterSeconds *int32 `bson:"expireAfterSeconds"`
+ }
+ if err := cursor.All(ctx, &indexes); err != nil {
+ continue
+ }
+ for _, idx := range indexes {
+ if idx.ExpireAfterSeconds != nil {
+ count++
+ }
+ }
}
+ return count
+}
+
+func getStatusDelta(ctx context.Context, client *mongo.Client, d time.Duration) (*statusDelta, error) {
+ var s1, s2 proto.ServerStatus
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{{Key: "serverStatus", Value: 1}}).Decode(&s1); err != nil {
+ return nil, errors.Wrap(err, "getStatusDelta.first")
+ }
+ time.Sleep(d)
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{{Key: "serverStatus", Value: 1}}).Decode(&s2); err != nil {
+ return nil, errors.Wrap(err, "getStatusDelta.second")
+ }
+ if s1.Opcounters == nil || s2.Opcounters == nil {
+ return nil, errors.New("opcounters unavailable")
+ }
+ secs := d.Seconds()
+ perDayMult := 86400.0 / secs
+ calc := func(a, b int64) counterDelta {
+ diff := float64(b - a)
+ return counterDelta{PerSec: diff / secs, PerDay: diff * perDayMult}
+ }
+ return &statusDelta{
+ Insert: calc(s1.Opcounters.Insert, s2.Opcounters.Insert),
+ Query: calc(s1.Opcounters.Query, s2.Opcounters.Query),
+ Update: calc(s1.Opcounters.Update, s2.Opcounters.Update),
+ Delete: calc(s1.Opcounters.Delete, s2.Opcounters.Delete),
+ GetMore: calc(s1.Opcounters.GetMore, s2.Opcounters.GetMore),
+ Command: calc(s1.Opcounters.Command, s2.Opcounters.Command),
+ Duration: d,
+ }, nil
+}
- if opts.Help {
- gop.PrintUsage(os.Stdout)
+func getWiredTigerInfo(ctx context.Context, client *mongo.Client) (*wiredTigerInfo, error) {
+ var ss proto.ServerStatus
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{{Key: "serverStatus", Value: 1}}).Decode(&ss); err != nil {
+ return nil, errors.Wrap(err, "getWiredTigerInfo.serverStatus")
+ }
+ if ss.WiredTiger == nil {
+ return nil, errors.New("WiredTiger not available on this node")
+ }
+ c := ss.WiredTiger.Cache
+ maxMB := float64(c.MaxBytesConfigured) / (1024 * 1024)
+ usedMB := float64(c.CurrentCachedBytes) / (1024 * 1024)
+ dirtyMB := float64(c.TrackedDirtyBytes) / (1024 * 1024)
+ usedPct, dirtyPct := 0.0, 0.0
+ if maxMB > 0 {
+ usedPct = usedMB / maxMB * 100
+ dirtyPct = dirtyMB / maxMB * 100
+ }
+ wi := &wiredTigerInfo{
+ CacheUsedMB: usedMB,
+ CacheMaxMB: maxMB,
+ CacheUsedPct: usedPct,
+ DirtyMB: dirtyMB,
+ DirtyPct: dirtyPct,
+ PagesEvictedUnmodified: c.PagesEvictedUnmodified,
+ PagesEvictedModified: c.PagesEvictedModified,
+ PagesReadIntoCache: c.PagesReadIntoCache,
+ PagesWrittenFromCache: c.PagesWrittenFromCache,
+ }
+ if ss.Tcmalloc != nil {
+ wi.TcmallocAvailable = true
+ wi.TcmallocAllocMB = float64(ss.Tcmalloc.Generic.CurrentAllocatedBytes) / (1024 * 1024)
+ wi.TcmallocHeapMB = float64(ss.Tcmalloc.Generic.HeapSize) / (1024 * 1024)
+ wi.TcmallocPageheapFreeMB = float64(ss.Tcmalloc.Tcmalloc.PageheapFreeBytes) / (1024 * 1024)
+ wi.TcmallocPageheapUnmappedMB = float64(ss.Tcmalloc.Tcmalloc.PageheapUnmappedBytes) / (1024 * 1024)
+ wi.TcmallocThreadCacheFreeMB = float64(ss.Tcmalloc.Tcmalloc.ThreadCacheFreeBytes) / (1024 * 1024)
+ }
+ return wi, nil
+}
- return nil, nil
+func getConnectionInfo(ctx context.Context, client *mongo.Client) (*connectionInfo, error) {
+ var ss proto.ServerStatus
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{{Key: "serverStatus", Value: 1}}).Decode(&ss); err != nil {
+ return nil, errors.Wrap(err, "getConnectionInfo.serverStatus")
+ }
+ ci := &connectionInfo{}
+ if ss.Connections != nil {
+ ci.Current = ss.Connections.Current
+ ci.Available = ss.Connections.Available
+ ci.TotalCreated = ss.Connections.TotalCreated
+ }
+ var cmdOpts proto.CommandLineOptions
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{{Key: "getCmdLineOpts", Value: 1}}).Decode(&cmdOpts); err == nil {
+ ci.MaxIncoming = cmdOpts.Parsed.Net.MaxIncomingConnections
+ ci.SlowOpThresholdMs = cmdOpts.Parsed.OperationProfiling.SlowOpThresholdMs
+ ci.ProfilingMode = cmdOpts.Parsed.OperationProfiling.Mode
}
+ return ci, nil
+}
- if opts.OutputFormat != "json" && opts.OutputFormat != "text" {
- log.Infof("Invalid output format '%s'. Using text format", opts.OutputFormat)
+func getFCV(ctx context.Context, client *mongo.Client) string {
+ var result struct {
+ FCV struct {
+ Version string `bson:"version"`
+ } `bson:"featureCompatibilityVersion"`
+ }
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{
+ {Key: "getParameter", Value: 1},
+ {Key: "featureCompatibilityVersion", Value: 1},
+ }).Decode(&result); err != nil {
+ return ""
}
+ return result.FCV.Version
+}
- return opts, nil
+func getChangeStreamInfo(ctx context.Context, client *mongo.Client) (*changeStreamInfo, error) {
+ var result struct {
+ ChangeStreamOptions struct {
+ PreAndPostImages struct {
+ ExpireAfterSeconds interface{} `bson:"expireAfterSeconds"`
+ } `bson:"preAndPostImages"`
+ } `bson:"changeStreamOptions"`
+ }
+ if err := client.Database("admin").RunCommand(ctx, primitive.D{
+ {Key: "getParameter", Value: 1},
+ {Key: "changeStreamOptions", Value: 1},
+ }).Decode(&result); err != nil {
+ return nil, errors.Wrap(err, "getChangeStreamInfo.getParameter")
+ }
+ expire := "off"
+ if v := result.ChangeStreamOptions.PreAndPostImages.ExpireAfterSeconds; v != nil {
+ expire = fmt.Sprintf("%v", v)
+ }
+ return &changeStreamInfo{PreAndPostImagesExpire: expire}, nil
}
+
func getChunksCount(ctx context.Context, client *mongo.Client) ([]proto.ChunksByCollection, error) {
var result []proto.ChunksByCollection
@@ -1090,6 +1464,9 @@ func getClientOptions(opts *cliOptions) (*options.ClientOptions, error) {
if opts.URI != "" {
clientOptions = options.Client().ApplyURI(opts.URI)
+ if strings.HasPrefix(opts.URI, "mongodb+srv://") {
+ clientOptions.Direct = nil
+ }
} else {
host := opts.Host
if host == "" {
@@ -1112,9 +1489,12 @@ func getClientOptions(opts *cliOptions) (*options.ClientOptions, error) {
auth.Username = opts.User
}
if opts.Password != "" {
- auth.Password = opts.Password
+ auth.Password = string(opts.Password)
auth.PasswordSet = true
}
+ if opts.AuthDB != "" && auth.AuthSource == "" {
+ auth.AuthSource = opts.AuthDB
+ }
if auth.Username != "" {
clientOptions.SetAuth(*auth)
diff --git a/src/go/pt-mongodb-summary/main_test.go b/src/go/pt-mongodb-summary/main_test.go
index 795453572..c8c1d9f71 100644
--- a/src/go/pt-mongodb-summary/main_test.go
+++ b/src/go/pt-mongodb-summary/main_test.go
@@ -18,13 +18,12 @@ import (
"time"
"context"
- "os"
- "reflect"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/mongo/options"
- "github.com/pborman/getopt"
+ "github.com/percona/percona-toolkit/src/go/lib/config"
+
"github.com/stretchr/testify/require"
tu "github.com/percona/percona-toolkit/src/go/internal/testutils"
@@ -114,79 +113,53 @@ func TestClusterWideInfo(t *testing.T) {
}
}
-func TestParseFlags(t *testing.T) {
+func TestAfterApply(t *testing.T) {
tests := []struct {
- name string
- args []string
- want *cliOptions
- wantErr bool
+ name string
+ input *cliOptions
+ wantErr bool
+ wantHost string
+ wantPort string
+ wantHostPort string
}{
{
- name: "Default values",
- args: []string{toolname},
- want: &cliOptions{
- Host: "",
- LogLevel: DefaultLogLevel,
- AuthDB: DefaultAuthDB,
- RunningOpsSamples: DefaultRunningOpsSamples,
- RunningOpsInterval: DefaultRunningOpsInterval,
- OutputFormat: "text",
- },
+ name: "Default values — no positional arg",
+ input: &cliOptions{LogLevel: "warn"},
},
{
- name: "URI only",
- args: []string{toolname, "--uri", "mongodb://test:27017"},
- want: &cliOptions{
- URI: "mongodb://test:27017",
- LogLevel: DefaultLogLevel,
- AuthDB: DefaultAuthDB,
- RunningOpsSamples: DefaultRunningOpsSamples,
- RunningOpsInterval: DefaultRunningOpsInterval,
- OutputFormat: "text",
- },
+ name: "Legacy positional host:port parsed",
+ input: &cliOptions{LogLevel: "warn", HostPort: "test.example.com:27019"},
+ wantHost: "test.example.com",
+ wantPort: "27019",
+ wantHostPort: "",
},
{
- name: "Legacy positional host:port",
- args: []string{toolname, "test.example.com:27019"},
- want: &cliOptions{
- Host: "test.example.com",
- Port: "27019",
- LogLevel: DefaultLogLevel,
- AuthDB: DefaultAuthDB,
- RunningOpsSamples: DefaultRunningOpsSamples,
- RunningOpsInterval: DefaultRunningOpsInterval,
- OutputFormat: "text",
- },
+ name: "Legacy positional host only (no port)",
+ input: &cliOptions{LogLevel: "warn", HostPort: "myhost"},
+ wantHost: "myhost",
+ wantPort: "",
+ wantHostPort: "",
},
{
name: "Error: URI and Host together",
- args: []string{toolname, "--uri", "mongodb://test", "--host", "localhost"},
+ input: &cliOptions{LogLevel: "warn", URI: "mongodb://test", Host: "localhost"},
wantErr: true,
},
{
name: "Error: Positional arg and Host flag together",
- args: []string{toolname, "--host", "newhost", "legacy:27017"},
+ input: &cliOptions{LogLevel: "warn", Host: "newhost", HostPort: "legacy:27017"},
wantErr: true,
},
{
- name: "Help flag returns nil options",
- args: []string{toolname, "--help"},
- want: nil,
+ name: "Error: Positional arg and URI together",
+ input: &cliOptions{LogLevel: "warn", URI: "mongodb://test", HostPort: "legacy:27017"},
+ wantErr: true,
},
}
- // Backup and silence stdout
- oldStdout := os.Stdout
- _, w, _ := os.Pipe()
- os.Stdout = w
- defer func() { os.Stdout = oldStdout }()
-
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- getopt.Reset()
- os.Args = tt.args
-
- got, err := parseFlags()
+ err := tt.input.AfterApply()
if tt.wantErr {
if err == nil {
@@ -200,8 +173,16 @@ func TestParseFlags(t *testing.T) {
return
}
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("mismatch:\ngot: %+v\nwant: %+v", got, tt.want)
+ if tt.wantHost != "" || tt.wantPort != "" {
+ if tt.input.Host != tt.wantHost {
+ t.Errorf("Host: got %q want %q", tt.input.Host, tt.wantHost)
+ }
+ if tt.input.Port != tt.wantPort {
+ t.Errorf("Port: got %q want %q", tt.input.Port, tt.wantPort)
+ }
+ if tt.input.HostPort != tt.wantHostPort {
+ t.Errorf("HostPort: got %q want %q", tt.input.HostPort, tt.wantHostPort)
+ }
}
})
}
@@ -236,7 +217,7 @@ func TestGetClientOptions(t *testing.T) {
opts: &cliOptions{
URI: "mongodb://old-user:old-pass@localhost:27017",
User: "new-user",
- Password: "new-password",
+ Password: config.StdinRequestString("new-password"),
},
validate: func(t *testing.T, co *options.ClientOptions) {
assert.Equal(t, "new-user", co.Auth.Username)
diff --git a/src/go/pt-mongodb-summary/oplog/oplog.go b/src/go/pt-mongodb-summary/oplog/oplog.go
index 86685ef2a..1c965f9cb 100644
--- a/src/go/pt-mongodb-summary/oplog/oplog.go
+++ b/src/go/pt-mongodb-summary/oplog/oplog.go
@@ -43,6 +43,17 @@ func GetOplogInfo(ctx context.Context, hostnames []string, co *options.ClientOpt
return nil, errors.Wrapf(err, "cannot connect to %s", hostname)
}
+ var helloResult struct {
+ ArbiterOnly bool `bson:"arbiterOnly"`
+ Msg string `bson:"msg"`
+ }
+ if imErr := client.Database("admin").RunCommand(ctx, bson.M{"isMaster": 1}).Decode(&helloResult); imErr == nil {
+ if helloResult.ArbiterOnly || helloResult.Msg == "isdbgrid" {
+ client.Disconnect(ctx) //nolint
+ continue
+ }
+ }
+
oplogCol, err := getOplogCollection(ctx, client)
if err != nil {
return nil, errors.Wrap(err, "cannot determine the oplog collection")
@@ -54,8 +65,8 @@ func GetOplogInfo(ctx context.Context, hostnames []string, co *options.ClientOpt
return nil, errors.Wrapf(err, "cannot get collStats for collection %s", oplogCol)
}
- result.Size = colStats.Size
result.UsedMB = colStats.Size / (1024 * 1024)
+ result.Size = colStats.MaxSize / (1024 * 1024)
var firstRow, lastRow proto.OplogRow
options := options.FindOne()
diff --git a/src/go/pt-mongodb-summary/templates/changestream.go b/src/go/pt-mongodb-summary/templates/changestream.go
new file mode 100644
index 000000000..8dd84bbf8
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/changestream.go
@@ -0,0 +1,21 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const ChangeStream = `
+{{ if . -}}
+# Change Stream ###########################################################################################
+ Pre/Post Images Expire | {{.PreAndPostImagesExpire}}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/cmdline.go b/src/go/pt-mongodb-summary/templates/cmdline.go
index 066def1b0..70854259c 100644
--- a/src/go/pt-mongodb-summary/templates/cmdline.go
+++ b/src/go/pt-mongodb-summary/templates/cmdline.go
@@ -14,8 +14,58 @@
package templates
const CmdlineArgs = `
-{{ if . }}
+{{ if . -}}
# Command line arguments
{{ range .CmdlineArgs -}} {{- . }} {{ end }}
+{{- if .ParsedConfig }}
+# Active Configuration
+{{- with .ParsedConfig.Parsed }}
+{{- if .Config }}
+ Config File | {{.Config}}
+{{- end }}
+{{- if .Net.Port }}
+ Port | {{.Net.Port}}
+{{- end }}
+{{- if .Net.BindIP }}
+ Bind IP | {{.Net.BindIP}}
+{{- end }}
+{{- if .Net.MaxIncomingConnections }}
+ Max Incoming Conns | {{.Net.MaxIncomingConnections}}
+{{- end }}
+{{- if .Net.SSL.Mode }}
+ SSL | {{.Net.SSL.Mode}}
+{{- end }}
+{{- if .Storage.DbPath }}
+ Data Path | {{.Storage.DbPath}}
+{{- end }}
+{{- if .Storage.Engine }}
+ Storage Engine | {{.Storage.Engine}}
+{{- end }}
+{{- if .Storage.WiredTiger.EngineConfig.CacheSizeGB }}
+ WT Cache Size | {{.Storage.WiredTiger.EngineConfig.CacheSizeGB}} GB
+{{- end }}
+{{- if .Replication.ReplSet }}
+ ReplSet | {{.Replication.ReplSet}}
+{{- end }}
+{{- if .Security.Authorization }}
+ Authorization | {{.Security.Authorization}}
+{{- end }}
+{{- if .Security.KeyFile }}
+ Key File | {{.Security.KeyFile}}
+{{- end }}
+{{- if .SystemLog.Path }}
+ Log Path | {{.SystemLog.Path}}
+{{- end }}
+{{- if .SystemLog.LogAppend }}
+ Log Append | true
+{{- end }}
+{{- if .OperationProfiling.Mode }}
+ Profiling Mode | {{.OperationProfiling.Mode}}
+{{- end }}
+{{- if .OperationProfiling.SlowOpThresholdMs }}
+ Slow Op Threshold | {{.OperationProfiling.SlowOpThresholdMs}} ms
+{{- end }}
+{{- end }}
+{{- end }}
{{- end }}
`
diff --git a/src/go/pt-mongodb-summary/templates/connections.go b/src/go/pt-mongodb-summary/templates/connections.go
new file mode 100644
index 000000000..6cb9aa6ff
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/connections.go
@@ -0,0 +1,32 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const Connections = `
+{{ if . -}}
+# Connections & Profiling #################################################################################
+ Current | {{.Current}}
+ Available | {{.Available}}
+ Total Created | {{.TotalCreated}}
+{{- if .MaxIncoming }}
+ Max Incoming | {{.MaxIncoming}}
+{{- end }}
+{{- if .ProfilingMode }}
+ Profiling Mode | {{.ProfilingMode}}
+{{- end }}
+{{- if .SlowOpThresholdMs }}
+ Slow Op Threshold | {{.SlowOpThresholdMs}} ms
+{{- end }}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/dbinventory.go b/src/go/pt-mongodb-summary/templates/dbinventory.go
new file mode 100644
index 000000000..902f33f56
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/dbinventory.go
@@ -0,0 +1,24 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const DBInventory = `
+{{ if . -}}
+# Database Inventory #####################################################################################
+ {{printf "%-30s %8s %-5s %11s %9s %10s" "Database" "Size" "" "Collections" "Indexes" "TTL Indexes"}}
+{{- range . }}
+ {{printf "%-30s %8.2f %-5s %11d %9d %11d" .Name .SizeScaled .SizeUnit .Collections .Indexes .TTLIndexes}}
+{{- end }}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/hostinfo.go b/src/go/pt-mongodb-summary/templates/hostinfo.go
index 3e31f3a6b..b27fa4600 100644
--- a/src/go/pt-mongodb-summary/templates/hostinfo.go
+++ b/src/go/pt-mongodb-summary/templates/hostinfo.go
@@ -25,6 +25,9 @@ const HostInfo = `# This host
PID Owner | {{.ProcessName}}
Hostname | {{.Hostname}}
Version | {{.Version}}
+{{- if .FCV }}
+ FCV | {{.FCV}}
+{{- end }}
Built On | {{.HostOsType}} {{.HostSystemCPUArch}}
Started | {{.ProcCreateTime}}
{{- if .DBPath }}
diff --git a/src/go/pt-mongodb-summary/templates/oplog.go b/src/go/pt-mongodb-summary/templates/oplog.go
index 3ff99c259..a7eb298f1 100644
--- a/src/go/pt-mongodb-summary/templates/oplog.go
+++ b/src/go/pt-mongodb-summary/templates/oplog.go
@@ -15,8 +15,9 @@ package templates
const Oplog = `
# Oplog ##################################################################################################
-Oplog Size {{.Size}} Mb
-Oplog Used {{.UsedMB}} Mb
-Oplog Length {{.Running}}
-Last Election {{.ElectionTime}}
+{{- range .Nodes }}
+ {{printf "%-40s" .Hostname}} {{printf "%8d" .UsedMB}} MB used / {{printf "%8d" .Size}} MB Length: {{.Running}}{{if not .ElectionTime.IsZero}} Election: {{.ElectionTime.Format "2006-01-02T15:04:05Z"}}{{end}}
+{{- end }}
+ Min Oplog Host | {{.MinHost}}
+ Max Oplog Host | {{.MaxHost}}
`
diff --git a/src/go/pt-mongodb-summary/templates/oschecks.go b/src/go/pt-mongodb-summary/templates/oschecks.go
new file mode 100644
index 000000000..66e00448d
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/oschecks.go
@@ -0,0 +1,27 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const OSChecks = `
+{{ if . -}}
+# OS & Production Checks #################################################################################
+ Kernel Version | {{.KernelVersion}}
+ MemSize | {{printf "%.0f" .MemSizeMB}} MB
+ Num Cores | {{printf "%.0f" .NumCores}}
+ NUMA Enabled | {{.NUMAEnabled}}
+ Open Files Limit | {{printf "%.0f" .OpenFilesLimit}}{{if .OpenFilesWarn}} [WARNING: recommended >= 65535]{{end}}
+ vm.max_map_count | {{if eq .VMMaxMapCount 0}}unknown{{else}}{{.VMMaxMapCount}}{{if .VMMaxMapWarn}} [WARNING: recommended >= 262144]{{end}}{{end}}
+ TasksMax | {{if .TasksMax}}{{.TasksMax}}{{else}}unknown{{end}}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/shardsinfo.go b/src/go/pt-mongodb-summary/templates/shardsinfo.go
new file mode 100644
index 000000000..25a99c3d4
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/shardsinfo.go
@@ -0,0 +1,23 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const ShardsInfo = `
+{{ if . -}}
+# Shards #################################################################################################
+{{ range .Shards -}}
+ {{printf "%-20s" .ID}} {{.Host}}
+{{ end }}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/status_delta.go b/src/go/pt-mongodb-summary/templates/status_delta.go
new file mode 100644
index 000000000..a8c13a1ac
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/status_delta.go
@@ -0,0 +1,27 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const StatusDelta = `
+{{ if . -}}
+# Opcount Deltas ({{.Duration}}) ########################################################################
+ Type Per Second Per Day
+ Insert {{printf "%10.2f" .Insert.PerSec}} {{printf "%14.0f" .Insert.PerDay}}
+ Query {{printf "%10.2f" .Query.PerSec}} {{printf "%14.0f" .Query.PerDay}}
+ Update {{printf "%10.2f" .Update.PerSec}} {{printf "%14.0f" .Update.PerDay}}
+ Delete {{printf "%10.2f" .Delete.PerSec}} {{printf "%14.0f" .Delete.PerDay}}
+ GetMore {{printf "%10.2f" .GetMore.PerSec}} {{printf "%14.0f" .GetMore.PerDay}}
+ Command {{printf "%10.2f" .Command.PerSec}} {{printf "%14.0f" .Command.PerDay}}
+{{- end }}
+`
diff --git a/src/go/pt-mongodb-summary/templates/wiredtiger.go b/src/go/pt-mongodb-summary/templates/wiredtiger.go
new file mode 100644
index 000000000..dfe1c94a4
--- /dev/null
+++ b/src/go/pt-mongodb-summary/templates/wiredtiger.go
@@ -0,0 +1,34 @@
+// This program is copyright 2016-2026 Percona LLC and/or its affiliates.
+//
+// THIS PROGRAM IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
+// WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
+//
+// This program is free software; you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, version 2.
+//
+// You should have received a copy of the GNU General Public License, version 2
+// along with this program; if not, see .
+
+package templates
+
+const WiredTiger = `
+{{ if . -}}
+# WiredTiger ##########################################################################################
+ Cache Used | {{printf "%.2f" .CacheUsedMB}} MB / {{printf "%.2f" .CacheMaxMB}} MB ({{printf "%.1f" .CacheUsedPct}}%)
+ Dirty Pages | {{printf "%.2f" .DirtyMB}} MB ({{printf "%.1f" .DirtyPct}}%)
+ Evicted Unmodified | {{.PagesEvictedUnmodified}}
+ Evicted Modified | {{.PagesEvictedModified}}
+ Read Into Cache | {{.PagesReadIntoCache}}
+ Written Fr Cache | {{.PagesWrittenFromCache}}
+{{- if .TcmallocAvailable }}
+# TCMalloc ############################################################################################
+ Heap Size | {{printf "%.2f" .TcmallocHeapMB}} MB
+ Allocated (RSS) | {{printf "%.2f" .TcmallocAllocMB}} MB
+ Pageheap Free | {{printf "%.2f" .TcmallocPageheapFreeMB}} MB
+ Pageheap Unmapped | {{printf "%.2f" .TcmallocPageheapUnmappedMB}} MB
+ Thread Cache Free | {{printf "%.2f" .TcmallocThreadCacheFreeMB}} MB
+{{- end }}
+{{- end }}
+`