From 04110f4eab78bf6af58e6dffb29623574ad9c932 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 16:53:51 -0500 Subject: [PATCH 1/9] pt-mongodb-summary: add OSChecks, DBInventory, StatusDelta, WiredTiger, ShardsInfo sections - New output sections: OS production checks, database inventory, opcount deltas (10s sample), WiredTiger cache metrics, shards list - Arbiter-aware: skip oplog/runningops/statusdelta on arbiter nodes - Robust proc info: downgrade errors to debug in restricted environments - Oplog template now shows all replica nodes (min/max host) - ParsedConfig stored on hostInfo for cmdline template use --- src/go/mongolib/proto/server_status.go | 10 +- src/go/pt-mongodb-summary/main.go | 317 ++++++++++++++++-- src/go/pt-mongodb-summary/oplog/oplog.go | 11 + .../pt-mongodb-summary/templates/cmdline.go | 28 +- .../templates/dbinventory.go | 24 ++ src/go/pt-mongodb-summary/templates/oplog.go | 9 +- .../pt-mongodb-summary/templates/oschecks.go | 27 ++ .../templates/shardsinfo.go | 23 ++ .../templates/status_delta.go | 27 ++ .../templates/wiredtiger.go | 26 ++ 10 files changed, 459 insertions(+), 43 deletions(-) create mode 100644 src/go/pt-mongodb-summary/templates/dbinventory.go create mode 100644 src/go/pt-mongodb-summary/templates/oschecks.go create mode 100644 src/go/pt-mongodb-summary/templates/shardsinfo.go create mode 100644 src/go/pt-mongodb-summary/templates/status_delta.go create mode 100644 src/go/pt-mongodb-summary/templates/wiredtiger.go diff --git a/src/go/mongolib/proto/server_status.go b/src/go/mongolib/proto/server_status.go index 375c0e08b..3058abac4 100644 --- a/src/go/mongolib/proto/server_status.go +++ b/src/go/mongolib/proto/server_status.go @@ -67,9 +67,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/main.go b/src/go/pt-mongodb-summary/main.go index 020356521..d39d42879 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -112,7 +112,8 @@ type hostInfo struct { ProcCreateTime time.Time ProcProcessCount int - CmdlineArgs []string + CmdlineArgs []string + ParsedConfig *proto.CommandLineOptions // Server Status ProcessName string ReplicasetName string @@ -214,9 +215,67 @@ type collectedInfo struct { SecuritySettings *security HostInfo *hostInfo MongosInfo *mongosInfo + OSChecks *osProductionChecks + DBInventory []dbInventoryEntry + StatusDelta *statusDelta + WiredTiger *wiredTigerInfo + ShardsInfo *proto.ShardsInfo Errors []string } +type osProductionChecks struct { + KernelVersion string + MemSizeMB float64 + NumCores float64 + NUMAEnabled bool + OpenFilesLimit float64 + OpenFilesWarn bool + VMMaxMapCount int64 + VMMaxMapWarn bool + TasksMax string +} + +type oplogDisplay struct { + Nodes []proto.OplogInfo + MinHost string + MaxHost string +} + +type dbInventoryEntry struct { + Name string + SizeScaled float64 + SizeUnit string + Collections int64 + Indexes int64 +} + +type counterDelta struct { + PerSec float64 + PerDay float64 +} + +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 +} + func main() { opts, err := parseFlags() if err != nil { @@ -271,7 +330,11 @@ 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) } @@ -301,7 +364,9 @@ func main() { 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 { ci.RunningOps, err = getOpCountersStats( ctx, client, opts.RunningOpsSamples, time.Duration(opts.RunningOpsInterval)*time.Millisecond, @@ -319,13 +384,31 @@ 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 { + ci.OSChecks, err = getOSProductionChecks(ctx, client) + if err != nil { + log.Warnf("[Warning] cannot get OS production checks: %v\n", err) + } + + ci.DBInventory, err = getDatabaseInventory(ctx, client) + if err != nil { + log.Warnf("[Warning] cannot get database inventory: %v\n", err) + } + + if !isArbiter { + 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] + } + + ci.StatusDelta, err = getStatusDelta(ctx, client, 10*time.Second) + if err != nil { + log.Warnf("[Warning] cannot get status delta: %v\n", err) + } + + ci.WiredTiger, err = getWiredTigerInfo(ctx, client) + if err != nil { + log.Debugf("WiredTiger metrics not available: %v", err) } } @@ -334,6 +417,10 @@ func main() { if ci.ClusterWideInfo, err = getClusterwideInfo(ctx, client); err != nil { log.Printf("[Error] cannot get cluster wide info: %v\n", err) } + + if ci.ShardsInfo, err = getShardsInfo(ctx, client); err != nil { + log.Warnf("[Warning] cannot get shards info: %v\n", err) + } } if ci.HostInfo.NodeType == typeMongos { @@ -395,18 +482,48 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) { return nil, errors.Wrap(err, "cannot parse ssl section of the output template") } - if ci.OplogInfo != nil && len(ci.OplogInfo) > 0 { + 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") + } + + 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") } } + 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") + } + + 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") + } + 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") } + 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("balancer").Parse(templates.BalancerStats)) if err := t.Execute(buf, ci.BalancerStats); err != nil { return nil, errors.Wrap(err, "cannot parse balancer section of the output template") @@ -446,6 +563,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 +574,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 +592,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 +951,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,6 +1091,138 @@ func externalIP() (string, error) { return "", errors.New("are you connected to the network?") } +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 + } + } + } + return c, nil +} + +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 +} + +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"` + } + 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 + } + scaled, unit := sizeAndUnit(int64(stats.DataSize)) + result = append(result, dbInventoryEntry{ + Name: db.Name, + SizeScaled: scaled, + SizeUnit: unit, + Collections: stats.Collections, + Indexes: stats.Indexes, + }) + } + return result, nil +} + +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 +} + +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 + } + return &wiredTigerInfo{ + CacheUsedMB: usedMB, + CacheMaxMB: maxMB, + CacheUsedPct: usedPct, + DirtyMB: dirtyMB, + DirtyPct: dirtyPct, + PagesEvictedUnmodified: c.PagesEvictedUnmodified, + PagesEvictedModified: c.PagesEvictedModified, + PagesReadIntoCache: c.PagesReadIntoCache, + PagesWrittenFromCache: c.PagesWrittenFromCache, + }, nil +} + func parseFlags() (*cliOptions, error) { opts := &cliOptions{ LogLevel: DefaultLogLevel, @@ -1090,6 +1334,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 == "" { diff --git a/src/go/pt-mongodb-summary/oplog/oplog.go b/src/go/pt-mongodb-summary/oplog/oplog.go index 86685ef2a..9462e5b7e 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") diff --git a/src/go/pt-mongodb-summary/templates/cmdline.go b/src/go/pt-mongodb-summary/templates/cmdline.go index 066def1b0..ed0979da8 100644 --- a/src/go/pt-mongodb-summary/templates/cmdline.go +++ b/src/go/pt-mongodb-summary/templates/cmdline.go @@ -14,8 +14,34 @@ package templates const CmdlineArgs = ` -{{ if . }} +{{ if . -}} # Command line arguments {{ range .CmdlineArgs -}} {{- . }} {{ end }} +{{- if .ParsedConfig }} +# Active Configuration +{{- with .ParsedConfig.Parsed }} +{{- if .Net.Port }} + Port | {{.Net.Port}} +{{- end }} +{{- if .Net.BindIP }} + Bind IP | {{.Net.BindIP}} +{{- 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 .Replication.ReplSet }} + ReplSet | {{.Replication.ReplSet}} +{{- end }} +{{- if .Security.Authorization }} + Authorization | {{.Security.Authorization}} +{{- end }} +{{- end }} +{{- 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..a8f93cae8 --- /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 ##################################################################################### + Database Size Collections Indexes +{{- range . }} + {{printf "%-30s" .Name}} {{printf "%8.2f %-3s" .SizeScaled .SizeUnit}} {{printf "%11d" .Collections}} {{printf "%9d" .Indexes}} +{{- end }} +{{- end }} +` diff --git a/src/go/pt-mongodb-summary/templates/oplog.go b/src/go/pt-mongodb-summary/templates/oplog.go index 3ff99c259..d5c66a24f 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}} Election: {{.ElectionTime.Format "2006-01-02T15:04:05Z"}} +{{- 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..1dbb02437 --- /dev/null +++ b/src/go/pt-mongodb-summary/templates/wiredtiger.go @@ -0,0 +1,26 @@ +// 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}} +{{- end }} +` From 02e6468b4128d1a0c051ab8112f11b8ec08450c9 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 17:21:07 -0500 Subject: [PATCH 2/9] pt-mongodb-summary: fix oplog size display and reorder output sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix oplog Size field: was storing raw bytes (collStats.Size), now stores MaxSize/1024/1024 so template displays correct MB value - Reorder output: host → OS checks → config → security → replication → sharding → db inventory → running ops → status delta → wiredtiger --- src/go/pt-mongodb-summary/main.go | 75 +++++++++++++----------- src/go/pt-mongodb-summary/oplog/oplog.go | 2 +- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go index d39d42879..530576f38 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -452,39 +452,33 @@ 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") - } - + // 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") } - 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") + // 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 { @@ -499,11 +493,39 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) { } } + // 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") + } + + t = template.Must(template.New("balancer").Parse(templates.BalancerStats)) + 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") @@ -513,21 +535,6 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) { if err := t.Execute(buf, ci.WiredTiger); err != nil { return nil, errors.Wrap(err, "cannot parse wiredtiger 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") - } - - 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("balancer").Parse(templates.BalancerStats)) - if err := t.Execute(buf, ci.BalancerStats); err != nil { - return nil, errors.Wrap(err, "cannot parse balancer section of the output template") - } } return buf.Bytes(), nil diff --git a/src/go/pt-mongodb-summary/oplog/oplog.go b/src/go/pt-mongodb-summary/oplog/oplog.go index 9462e5b7e..1c965f9cb 100644 --- a/src/go/pt-mongodb-summary/oplog/oplog.go +++ b/src/go/pt-mongodb-summary/oplog/oplog.go @@ -65,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() From 87cb031bf835d4dae7fa2972741013f911ddaea8 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 17:35:38 -0500 Subject: [PATCH 3/9] pt-mongodb-summary: migrate CLI parsing from pborman/getopt to kong (PT-2368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace pborman/getopt with kong via config.Setup() — enables config file support (~/.pt-mongodb-summary.conf, /etc/percona-toolkit/) - cliOptions now uses kong struct tags with defaults - AfterApply() handles: version print, log level, version check, password prompt, positional host[:port] parsing, flag conflict checks - Fix AuthDB flag never being applied to MongoDB auth credentials - Fix AuthDB not overriding URI authSource (URI takes precedence) - Rewrite TestParseFlags as TestAfterApply using direct AfterApply() calls - Remove pborman/getopt import entirely --- src/go/pt-mongodb-summary/CLAUDE.md | 76 +++++++++ src/go/pt-mongodb-summary/main.go | 207 ++++++++++--------------- src/go/pt-mongodb-summary/main_test.go | 95 +++++------- 3 files changed, 192 insertions(+), 186 deletions(-) create mode 100644 src/go/pt-mongodb-summary/CLAUDE.md 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 530576f38..9beca824f 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -32,7 +32,6 @@ import ( "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" @@ -188,22 +187,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 { @@ -277,43 +331,16 @@ type wiredTigerInfo struct { } func main() { - opts, err := parseFlags() - if err != nil { - log.Errorf("cannot get parameters: %s", err.Error()) - + opts := &cliOptions{} + if _, _, err := config.Setup(toolname, opts); err != nil { + log.Errorf("cannot get parameters: %s", err) os.Exit(cannotParseCommandLineParameters) } - if opts == nil && err == nil { - return - } - - logLevel, err := log.ParseLevel(opts.LogLevel) - if err != nil { - fmt.Printf("cannot set log level: %s", err.Error()) - } - - log.SetLevel(logLevel) - 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) - return } - 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) - } - } - ctx := context.Background() clientOptions, err := getClientOptions(opts) if err != nil { @@ -1230,87 +1257,6 @@ func getWiredTigerInfo(ctx context.Context, client *mongo.Client) (*wiredTigerIn }, nil } -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 - } - gop.Parse(gop.Args()) - } - - if gop.IsSet("password") && opts.Password == "" { - print("Password: ") - - pass, err := term.ReadPassword(0) - if err != nil { - return opts, err - } - - opts.Password = string(pass) - } - - 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") - } - - if opts.Help { - gop.PrintUsage(os.Stdout) - - return nil, nil - } - - if opts.OutputFormat != "json" && opts.OutputFormat != "text" { - log.Infof("Invalid output format '%s'. Using text format", opts.OutputFormat) - } - - return opts, nil -} func getChunksCount(ctx context.Context, client *mongo.Client) ([]proto.ChunksByCollection, error) { var result []proto.ChunksByCollection @@ -1366,9 +1312,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) From b021af34ba93e7e1f6d28e79b076ebd066bca9a8 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 17:40:46 -0500 Subject: [PATCH 4/9] pt-mongodb-summary: hide Election field when no primary found --- src/go/pt-mongodb-summary/templates/oplog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/go/pt-mongodb-summary/templates/oplog.go b/src/go/pt-mongodb-summary/templates/oplog.go index d5c66a24f..a7eb298f1 100644 --- a/src/go/pt-mongodb-summary/templates/oplog.go +++ b/src/go/pt-mongodb-summary/templates/oplog.go @@ -16,7 +16,7 @@ package templates const Oplog = ` # Oplog ################################################################################################## {{- range .Nodes }} - {{printf "%-40s" .Hostname}} {{printf "%8d" .UsedMB}} MB used / {{printf "%8d" .Size}} MB Length: {{.Running}} Election: {{.ElectionTime.Format "2006-01-02T15:04:05Z"}} + {{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}} From 2fed775701be5ae8eea18dde9c827e1c4ec61d45 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 17:55:22 -0500 Subject: [PATCH 5/9] pt-mongodb-summary: fix DB inventory column misalignment Unit field was %-3s but "bytes" is 5 chars, causing Collections/Indexes columns to shift right. Changed to %-5s and use single printf per row for guaranteed alignment between header and data. --- src/go/pt-mongodb-summary/templates/dbinventory.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/go/pt-mongodb-summary/templates/dbinventory.go b/src/go/pt-mongodb-summary/templates/dbinventory.go index a8f93cae8..edb6a0718 100644 --- a/src/go/pt-mongodb-summary/templates/dbinventory.go +++ b/src/go/pt-mongodb-summary/templates/dbinventory.go @@ -16,9 +16,9 @@ package templates const DBInventory = ` {{ if . -}} # Database Inventory ##################################################################################### - Database Size Collections Indexes + {{printf "%-30s %8s %-5s %11s %9s" "Database" "Size" "" "Collections" "Indexes"}} {{- range . }} - {{printf "%-30s" .Name}} {{printf "%8.2f %-3s" .SizeScaled .SizeUnit}} {{printf "%11d" .Collections}} {{printf "%9d" .Indexes}} + {{printf "%-30s %8.2f %-5s %11d %9d" .Name .SizeScaled .SizeUnit .Collections .Indexes}} {{- end }} {{- end }} ` From db4ee0279d58585e799aa952406d9de8e9713c00 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Tue, 21 Apr 2026 18:04:14 -0500 Subject: [PATCH 6/9] pt-mongodb-summary: fix HTML entity escaping in output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html/template escapes + as + in plain text output. Switch to text/template — no HTML escaping needed here. --- src/go/pt-mongodb-summary/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go index 9beca824f..fd2aece08 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -21,7 +21,7 @@ import ( "crypto/x509" "encoding/json" "fmt" - "html/template" + "text/template" "io/ioutil" "net" "os" From e3a3d1678599ba8c1e1cbb9bd37a5a30ff5ab790 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Wed, 22 Apr 2026 12:02:02 -0500 Subject: [PATCH 7/9] pt-mongodb-summary: add FCV, connections, tcmalloc, TTL, change stream sections - FCV from getParameter shown in host section next to Version - Connections section: current/available/totalCreated + maxIncoming from config - Profiling: slow op threshold (ms) and mode from getCmdLineOpts - tcmalloc subsection in WiredTiger: heap, allocated, pageheap free/unmapped, thread cache free (from serverStatus.tcmalloc) - TTL Indexes column in Database Inventory (counts TTL indexes per database) - Change Stream section: preAndPostImages expireAfterSeconds from getParameter - Proto: add OperationProfiling to cmdlineopts.Parsed; add TcmallocStats to ServerStatus --- src/go/mongolib/proto/cmdlineopts.go | 20 ++- src/go/mongolib/proto/server_status.go | 17 +++ src/go/pt-mongodb-summary/main.go | 143 +++++++++++++++++- .../templates/changestream.go | 21 +++ .../pt-mongodb-summary/templates/cmdline.go | 9 ++ .../templates/connections.go | 32 ++++ .../templates/dbinventory.go | 4 +- .../pt-mongodb-summary/templates/hostinfo.go | 3 + .../templates/wiredtiger.go | 8 + 9 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/go/pt-mongodb-summary/templates/changestream.go create mode 100644 src/go/pt-mongodb-summary/templates/connections.go diff --git a/src/go/mongolib/proto/cmdlineopts.go b/src/go/mongolib/proto/cmdlineopts.go index e3f314b73..6a69c2936 100644 --- a/src/go/mongolib/proto/cmdlineopts.go +++ b/src/go/mongolib/proto/cmdlineopts.go @@ -35,14 +35,20 @@ type CloSystemLog struct { Path string `bson:"path"` } +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"` + 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 3058abac4..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 { diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go index fd2aece08..96d83fa61 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -118,6 +118,7 @@ type hostInfo struct { ReplicasetName string Version string NodeType string + FCV string } type procInfo struct { @@ -274,9 +275,24 @@ type collectedInfo struct { StatusDelta *statusDelta WiredTiger *wiredTigerInfo ShardsInfo *proto.ShardsInfo + Connections *connectionInfo + ChangeStream *changeStreamInfo Errors []string } +type connectionInfo struct { + Current int64 + Available int64 + TotalCreated int64 + MaxIncoming int + SlowOpThresholdMs int64 + ProfilingMode string +} + +type changeStreamInfo struct { + PreAndPostImagesExpire string +} + type osProductionChecks struct { KernelVersion string MemSizeMB float64 @@ -301,6 +317,7 @@ type dbInventoryEntry struct { SizeUnit string Collections int64 Indexes int64 + TTLIndexes int64 } type counterDelta struct { @@ -328,6 +345,13 @@ type wiredTigerInfo struct { PagesEvictedModified int64 PagesReadIntoCache int64 PagesWrittenFromCache int64 + // tcmalloc + TcmallocAvailable bool + TcmallocAllocMB float64 + TcmallocHeapMB float64 + TcmallocPageheapFreeMB float64 + TcmallocPageheapUnmappedMB float64 + TcmallocThreadCacheFreeMB float64 } func main() { @@ -411,11 +435,23 @@ func main() { log.Warn("Cannot check security settings since host info is not available (permissions?)") } + ci.HostInfo.FCV = getFCV(ctx, client) + ci.OSChecks, err = getOSProductionChecks(ctx, client) if err != nil { log.Warnf("[Warning] cannot get OS production checks: %v\n", err) } + ci.Connections, err = getConnectionInfo(ctx, client) + if err != nil { + log.Warnf("[Warning] cannot get connection info: %v\n", err) + } + + ci.ChangeStream, err = getChangeStreamInfo(ctx, client) + if err != nil { + log.Debugf("change stream info not available: %v", err) + } + ci.DBInventory, err = getDatabaseInventory(ctx, client) if err != nil { log.Warnf("[Warning] cannot get database inventory: %v\n", err) @@ -496,6 +532,16 @@ func formatResults(ci *collectedInfo, format string) ([]byte, error) { return nil, errors.Wrap(err, "cannot parse the command line args 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 { @@ -1186,6 +1232,7 @@ func getDatabaseInventory(ctx context.Context, client *mongo.Client) ([]dbInvent 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, @@ -1193,11 +1240,38 @@ func getDatabaseInventory(ctx context.Context, client *mongo.Client) ([]dbInvent SizeUnit: unit, Collections: stats.Collections, Indexes: stats.Indexes, + TTLIndexes: ttl, }) } return result, nil } +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 { @@ -1244,7 +1318,7 @@ func getWiredTigerInfo(ctx context.Context, client *mongo.Client) (*wiredTigerIn usedPct = usedMB / maxMB * 100 dirtyPct = dirtyMB / maxMB * 100 } - return &wiredTigerInfo{ + wi := &wiredTigerInfo{ CacheUsedMB: usedMB, CacheMaxMB: maxMB, CacheUsedPct: usedPct, @@ -1254,7 +1328,72 @@ func getWiredTigerInfo(ctx context.Context, client *mongo.Client) (*wiredTigerIn PagesEvictedModified: c.PagesEvictedModified, PagesReadIntoCache: c.PagesReadIntoCache, PagesWrittenFromCache: c.PagesWrittenFromCache, - }, nil + } + 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 +} + +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 +} + +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 +} + +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 } 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 ed0979da8..035038690 100644 --- a/src/go/pt-mongodb-summary/templates/cmdline.go +++ b/src/go/pt-mongodb-summary/templates/cmdline.go @@ -41,6 +41,15 @@ const CmdlineArgs = ` {{- if .Security.Authorization }} Authorization | {{.Security.Authorization}} {{- end }} +{{- if .OperationProfiling.Mode }} + Profiling Mode | {{.OperationProfiling.Mode}} +{{- end }} +{{- if .OperationProfiling.SlowOpThresholdMs }} + Slow Op Threshold | {{.OperationProfiling.SlowOpThresholdMs}} ms +{{- end }} +{{- if .Net.MaxIncomingConnections }} + Max Incoming Conns | {{.Net.MaxIncomingConnections}} +{{- 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 index edb6a0718..902f33f56 100644 --- a/src/go/pt-mongodb-summary/templates/dbinventory.go +++ b/src/go/pt-mongodb-summary/templates/dbinventory.go @@ -16,9 +16,9 @@ package templates const DBInventory = ` {{ if . -}} # Database Inventory ##################################################################################### - {{printf "%-30s %8s %-5s %11s %9s" "Database" "Size" "" "Collections" "Indexes"}} + {{printf "%-30s %8s %-5s %11s %9s %10s" "Database" "Size" "" "Collections" "Indexes" "TTL Indexes"}} {{- range . }} - {{printf "%-30s %8.2f %-5s %11d %9d" .Name .SizeScaled .SizeUnit .Collections .Indexes}} + {{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/wiredtiger.go b/src/go/pt-mongodb-summary/templates/wiredtiger.go index 1dbb02437..dfe1c94a4 100644 --- a/src/go/pt-mongodb-summary/templates/wiredtiger.go +++ b/src/go/pt-mongodb-summary/templates/wiredtiger.go @@ -22,5 +22,13 @@ const WiredTiger = ` 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 }} ` From 968d7bc3b48fb1e3ca4eeddad91292169447c66a Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Wed, 22 Apr 2026 12:29:09 -0500 Subject: [PATCH 8/9] pt-mongodb-summary: add PTDEBUG support and getCmdLineOpts fields - Add ptDebugf() helper: PTDEBUG=1 prints # file:line pid message to stderr, matching pt-archiver's _d() pattern for diagnosing slow collection steps - Sprinkle ptDebugf calls at every collection stage so PTDEBUG=1 shows exactly where time is spent (connect, hostInfo, opCounters, etc.) - Extend proto.Parsed with Config, OperationProfiling, WiredTiger cache size, LogAppend to decode getCmdLineOpts fields that were previously missing - Show new fields in cmdline template: config file, WT cache size, key file, log path, log append, max incoming conns, profiling mode, slow op threshold --- src/go/mongolib/proto/cmdlineopts.go | 13 ++++++- src/go/pt-mongodb-summary/main.go | 39 ++++++++++++++++++- .../pt-mongodb-summary/templates/cmdline.go | 33 +++++++++++----- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/go/mongolib/proto/cmdlineopts.go b/src/go/mongolib/proto/cmdlineopts.go index 6a69c2936..1e731ecb8 100644 --- a/src/go/mongolib/proto/cmdlineopts.go +++ b/src/go/mongolib/proto/cmdlineopts.go @@ -25,14 +25,22 @@ 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 { @@ -41,6 +49,7 @@ type OperationProfiling struct { } type Parsed struct { + Config string `bson:"config"` Sharding Sharding `bson:"sharding"` Storage CloStorage `bson:"storage"` SystemLog CloSystemLog `bson:"systemLog"` diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go index 96d83fa61..18d41344c 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -27,6 +27,7 @@ import ( "os" "os/user" "path/filepath" + "runtime" "slices" "strings" "time" @@ -81,6 +82,18 @@ 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\n", + filepath.Base(file), line, os.Getpid(), + fmt.Sprintf(format, args...)) +} + type TimedStats struct { Min int64 Max int64 @@ -388,6 +401,7 @@ func main() { } os.Exit(cannotConnectToMongoDB) } + ptDebugf("connected to MongoDB at %v", clientOptions.Hosts) defer client.Disconnect(ctx) // nolint @@ -395,29 +409,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) 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, @@ -425,9 +444,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) } @@ -435,40 +456,52 @@ func main() { log.Warn("Cannot check security settings since host info is not available (permissions?)") } + 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") } + 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) @@ -477,21 +510,25 @@ func main() { // 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) diff --git a/src/go/pt-mongodb-summary/templates/cmdline.go b/src/go/pt-mongodb-summary/templates/cmdline.go index 035038690..70854259c 100644 --- a/src/go/pt-mongodb-summary/templates/cmdline.go +++ b/src/go/pt-mongodb-summary/templates/cmdline.go @@ -20,35 +20,50 @@ const CmdlineArgs = ` {{- 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}} + Data Path | {{.Storage.DbPath}} {{- end }} {{- if .Storage.Engine }} - Storage Engine | {{.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}} + ReplSet | {{.Replication.ReplSet}} {{- end }} {{- if .Security.Authorization }} - Authorization | {{.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}} + Profiling Mode | {{.OperationProfiling.Mode}} {{- end }} {{- if .OperationProfiling.SlowOpThresholdMs }} - Slow Op Threshold | {{.OperationProfiling.SlowOpThresholdMs}} ms -{{- end }} -{{- if .Net.MaxIncomingConnections }} - Max Incoming Conns | {{.Net.MaxIncomingConnections}} + Slow Op Threshold | {{.OperationProfiling.SlowOpThresholdMs}} ms {{- end }} {{- end }} {{- end }} From 96772c1e1f566d8a5a1dca3563c4cd028feced95 Mon Sep 17 00:00:00 2001 From: Yunus UYANIK Date: Wed, 22 Apr 2026 16:06:09 -0500 Subject: [PATCH 9/9] pt-mongodb-summary: add timestamp to PTDEBUG output --- src/go/pt-mongodb-summary/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/go/pt-mongodb-summary/main.go b/src/go/pt-mongodb-summary/main.go index 18d41344c..7b0507665 100644 --- a/src/go/pt-mongodb-summary/main.go +++ b/src/go/pt-mongodb-summary/main.go @@ -89,8 +89,9 @@ func ptDebugf(format string, args ...interface{}) { return } _, file, line, _ := runtime.Caller(1) - fmt.Fprintf(os.Stderr, "# %s:%d %d %s\n", + 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...)) }