From 9d08b185d0e57d8bd4fba1ca30f2d2da2e1f379f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Apr 2026 14:33:41 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(jvm):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8C=81=E7=BB=AD=E7=9B=91=E6=8E=A7=E4=B8=8E=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增监控会话管理,支持启动、停止和历史查询 - JMX、Endpoint、Agent Provider 补齐监控快照采集能力 - JMX helper 增加内存、GC、线程、类加载采样并更新内嵌运行时 - 生成 Wails 监控接口绑定并补充后端回归测试 --- frontend/package.json.md5 | 2 +- frontend/wailsjs/go/app/App.d.ts | 6 + frontend/wailsjs/go/app/App.js | 12 + internal/app/methods_jvm_monitoring.go | 77 ++++ internal/app/methods_jvm_monitoring_test.go | 147 ++++++++ internal/jvm/agent_provider.go | 14 + internal/jvm/agent_provider_test.go | 58 ++- internal/jvm/http_provider.go | 14 + internal/jvm/http_provider_test.go | 46 +++ internal/jvm/jmx_helper.go | 105 +++++- internal/jvm/jmx_helper_process_other.go | 8 + internal/jvm/jmx_helper_process_windows.go | 15 + internal/jvm/jmx_helper_test.go | 16 + internal/jvm/jmx_helper_windows_test.go | 18 + internal/jvm/jmx_provider.go | 13 + internal/jvm/jmx_provider_test.go | 45 +++ .../jmxhelper_assets/jmx-helper-runtime.jar | Bin 23555 -> 26995 bytes internal/jvm/monitoring_manager.go | 351 +++++++++++++++++ internal/jvm/monitoring_manager_test.go | 353 ++++++++++++++++++ internal/jvm/monitoring_types.go | 90 +++++ .../src/com/gonavi/jmxhelper/JmxRuntime.java | 287 ++++++++++++++ 21 files changed, 1664 insertions(+), 13 deletions(-) create mode 100644 internal/app/methods_jvm_monitoring.go create mode 100644 internal/app/methods_jvm_monitoring_test.go create mode 100644 internal/jvm/jmx_helper_process_other.go create mode 100644 internal/jvm/jmx_helper_process_windows.go create mode 100644 internal/jvm/jmx_helper_windows_test.go create mode 100644 internal/jvm/monitoring_manager.go create mode 100644 internal/jvm/monitoring_manager_test.go create mode 100644 internal/jvm/monitoring_types.go diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 848588e..fa8a8b2 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -26a843d5fd071d0c7e9d8022e98eb4e3 \ No newline at end of file +571d014306268cf67665967059cda912 \ No newline at end of file diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 356fb87..a2e8813 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -134,6 +134,8 @@ export function JVMCancelDiagnosticCommand(arg1:connection.ConnectionConfig,arg2 export function JVMExecuteDiagnosticCommand(arg1:connection.ConnectionConfig,arg2:string,arg3:jvm.DiagnosticCommandRequest):Promise; +export function JVMGetMonitoringHistory(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function JVMGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise; export function JVMListAuditRecords(arg1:string,arg2:number):Promise; @@ -150,6 +152,10 @@ export function JVMProbeDiagnosticCapabilities(arg1:connection.ConnectionConfig) export function JVMStartDiagnosticSession(arg1:connection.ConnectionConfig,arg2:jvm.DiagnosticSessionRequest):Promise; +export function JVMStartMonitoring(arg1:connection.ConnectionConfig):Promise; + +export function JVMStopMonitoring(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function ListSQLDirectory(arg1:string):Promise; export function LogWindowDiagnostic(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 5eaffa3..d00aad1 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -258,6 +258,10 @@ export function JVMExecuteDiagnosticCommand(arg1, arg2, arg3) { return window['go']['app']['App']['JVMExecuteDiagnosticCommand'](arg1, arg2, arg3); } +export function JVMGetMonitoringHistory(arg1, arg2) { + return window['go']['app']['App']['JVMGetMonitoringHistory'](arg1, arg2); +} + export function JVMGetValue(arg1, arg2) { return window['go']['app']['App']['JVMGetValue'](arg1, arg2); } @@ -290,6 +294,14 @@ export function JVMStartDiagnosticSession(arg1, arg2) { return window['go']['app']['App']['JVMStartDiagnosticSession'](arg1, arg2); } +export function JVMStartMonitoring(arg1) { + return window['go']['app']['App']['JVMStartMonitoring'](arg1); +} + +export function JVMStopMonitoring(arg1, arg2) { + return window['go']['app']['App']['JVMStopMonitoring'](arg1, arg2); +} + export function ListSQLDirectory(arg1) { return window['go']['app']['App']['ListSQLDirectory'](arg1); } diff --git a/internal/app/methods_jvm_monitoring.go b/internal/app/methods_jvm_monitoring.go new file mode 100644 index 0000000..8c668e1 --- /dev/null +++ b/internal/app/methods_jvm_monitoring.go @@ -0,0 +1,77 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type jvmMonitoringService interface { + Start(ctx context.Context, cfg connection.ConnectionConfig, requestedMode string) (jvm.MonitoringSessionSnapshot, error) + GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error) + Stop(connectionID string, providerMode string) error +} + +var currentJVMMonitoringManager jvmMonitoringService = jvm.NewMonitoringManager() + +func (a *App) JVMStartMonitoring(cfg connection.ConnectionConfig) connection.QueryResult { + snapshot, err := currentJVMMonitoringManager.Start(a.ctx, cfg, "") + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: snapshot} +} + +func (a *App) JVMGetMonitoringHistory(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult { + connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + snapshot, err := currentJVMMonitoringManager.GetHistory(connectionID, resolvedMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: snapshot} +} + +func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult { + connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if err := currentJVMMonitoringManager.Stop(connectionID, resolvedMode); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: map[string]any{ + "connectionId": connectionID, + "providerMode": resolvedMode, + "status": "stopped", + }} +} + +func resolveJVMMonitoringLookup(cfg connection.ConnectionConfig, requestedMode string) (string, string, error) { + normalized, resolvedMode, err := jvm.ResolveProviderMode(cfg, requestedMode) + if err != nil { + return "", "", err + } + return resolveJVMMonitoringConnectionID(normalized), resolvedMode, nil +} + +func resolveJVMMonitoringConnectionID(cfg connection.ConnectionConfig) string { + if trimmed := strings.TrimSpace(cfg.ID); trimmed != "" { + return trimmed + } + host := strings.TrimSpace(cfg.Host) + if host == "" { + host = "unknown" + } + if cfg.Port > 0 { + return fmt.Sprintf("%s:%d", host, cfg.Port) + } + return host +} diff --git a/internal/app/methods_jvm_monitoring_test.go b/internal/app/methods_jvm_monitoring_test.go new file mode 100644 index 0000000..acb8b92 --- /dev/null +++ b/internal/app/methods_jvm_monitoring_test.go @@ -0,0 +1,147 @@ +package app + +import ( + "context" + "errors" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/jvm" +) + +type fakeJVMMonitoringManager struct { + startSnapshot jvm.MonitoringSessionSnapshot + startErr error + historySnapshot jvm.MonitoringSessionSnapshot + historyErr error + stopErr error + startCfg connection.ConnectionConfig + startMode string + historyConnection string + historyMode string + stopConnection string + stopMode string +} + +func (f *fakeJVMMonitoringManager) Start(_ context.Context, cfg connection.ConnectionConfig, mode string) (jvm.MonitoringSessionSnapshot, error) { + f.startCfg = cfg + f.startMode = mode + return f.startSnapshot, f.startErr +} + +func (f *fakeJVMMonitoringManager) GetHistory(connectionID string, providerMode string) (jvm.MonitoringSessionSnapshot, error) { + f.historyConnection = connectionID + f.historyMode = providerMode + return f.historySnapshot, f.historyErr +} + +func (f *fakeJVMMonitoringManager) Stop(connectionID string, providerMode string) error { + f.stopConnection = connectionID + f.stopMode = providerMode + return f.stopErr +} + +func swapJVMMonitoringManager(manager jvmMonitoringService) func() { + prev := currentJVMMonitoringManager + currentJVMMonitoringManager = manager + return func() { currentJVMMonitoringManager = prev } +} + +func TestJVMStartMonitoringReturnsManagerSnapshot(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + startSnapshot: jvm.MonitoringSessionSnapshot{ + ConnectionID: "conn-monitor", + ProviderMode: jvm.ModeEndpoint, + Running: true, + Points: []jvm.JVMMonitoringPoint{ + {Timestamp: 1713945600000, ThreadCount: 21}, + }, + }, + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMStartMonitoring(connection.ConnectionConfig{ + ID: "conn-monitor", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeEndpoint, + AllowedModes: []string{jvm.ModeEndpoint}, + }, + }) + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + snapshot, ok := res.Data.(jvm.MonitoringSessionSnapshot) + if !ok { + t.Fatalf("expected monitoring snapshot, got %#v", res.Data) + } + if !snapshot.Running || len(snapshot.Points) != 1 { + t.Fatalf("unexpected snapshot: %#v", snapshot) + } + if manager.startCfg.ID != "conn-monitor" { + t.Fatalf("expected manager to receive config ID, got %#v", manager.startCfg) + } +} + +func TestJVMGetMonitoringHistoryResolvesPreferredMode(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + historySnapshot: jvm.MonitoringSessionSnapshot{ + ConnectionID: "conn-history", + ProviderMode: jvm.ModeJMX, + Running: true, + }, + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMGetMonitoringHistory(connection.ConnectionConfig{ + ID: "conn-history", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeJMX, + AllowedModes: []string{jvm.ModeJMX}, + }, + }, "") + + if !res.Success { + t.Fatalf("expected success, got %+v", res) + } + if manager.historyConnection != "conn-history" || manager.historyMode != jvm.ModeJMX { + t.Fatalf("unexpected manager history args: connection=%q mode=%q", manager.historyConnection, manager.historyMode) + } +} + +func TestJVMStopMonitoringReturnsManagerError(t *testing.T) { + app := NewAppWithSecretStore(nil) + manager := &fakeJVMMonitoringManager{ + stopErr: errors.New("session not found"), + } + restore := swapJVMMonitoringManager(manager) + defer restore() + + res := app.JVMStopMonitoring(connection.ConnectionConfig{ + ID: "conn-stop", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: jvm.ModeAgent, + AllowedModes: []string{jvm.ModeAgent}, + }, + }, "") + + if res.Success { + t.Fatalf("expected failure, got %+v", res) + } + if res.Message != "session not found" { + t.Fatalf("expected message %q, got %#v", "session not found", res) + } + if manager.stopConnection != "conn-stop" || manager.stopMode != jvm.ModeAgent { + t.Fatalf("unexpected manager stop args: connection=%q mode=%q", manager.stopConnection, manager.stopMode) + } +} diff --git a/internal/jvm/agent_provider.go b/internal/jvm/agent_provider.go index e1f66f8..c21ccd2 100644 --- a/internal/jvm/agent_provider.go +++ b/internal/jvm/agent_provider.go @@ -91,6 +91,20 @@ func (p *AgentProvider) GetValue(ctx context.Context, cfg connection.ConnectionC return snapshot, nil } +func (p *AgentProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + runtime, err := newAgentRuntime(cfg) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + + var snapshot JVMMonitoringSnapshot + if err := runtime.doJSON(ctx, http.MethodGet, "get monitoring snapshot", "metrics", nil, nil, &snapshot); err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *AgentProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { runtime, err := newAgentRuntime(cfg) if err != nil { diff --git a/internal/jvm/agent_provider_test.go b/internal/jvm/agent_provider_test.go index e6db9e2..efba2d5 100644 --- a/internal/jvm/agent_provider_test.go +++ b/internal/jvm/agent_provider_test.go @@ -59,6 +59,51 @@ func TestAgentProviderListResourcesBuildsRequestAndDecodesResponse(t *testing.T) } } +func TestAgentProviderGetMonitoringSnapshotDecodesResponse(t *testing.T) { + provider := &AgentProvider{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", r.Method) + } + if r.URL.Path != "/gonavi/agent/jvm/metrics" { + t.Fatalf("expected path /gonavi/agent/jvm/metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 27, + HeapUsedBytes: 402653184, + GCCollectionCount: 128, + GCDeltaCount: 2, + ProcessCpuLoad: 0.29, + CommittedVirtualMemoryBytes: 2147483648, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "ConcurrentMarkSweep", + DurationMs: 12, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "gc.count", "cpu.process", "memory.virtual"}, + }) + })) + defer server.Close() + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newAgentProviderTestConfig(server.URL+"/gonavi/agent/jvm", 3), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if snapshot.Point.ThreadCount != 27 || snapshot.Point.GCDeltaCount != 2 || snapshot.Point.ProcessCpuLoad != 0.29 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].Name != "ConcurrentMarkSweep" { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.AvailableMetrics) != 5 { + t.Fatalf("unexpected available metrics: %#v", snapshot) + } +} + func TestAgentProviderRealAgentRoundTrip(t *testing.T) { if _, err := exec.LookPath("java"); err != nil { t.Skipf("java 不可用,跳过真实 Agent 集成测试: %v", err) @@ -214,7 +259,18 @@ func startAgentFixture(t *testing.T) agentFixtureProcess { t.Fatalf("write agent manifest failed: %v", err) } - agentJar := filepath.Join(t.TempDir(), "gonavi-test-agent.jar") + agentJarFile, err := os.CreateTemp("", "gonavi-test-agent-*.jar") + if err != nil { + t.Fatalf("create agent jar temp file failed: %v", err) + } + agentJar := agentJarFile.Name() + if closeErr := agentJarFile.Close(); closeErr != nil { + t.Fatalf("close agent jar temp file failed: %v", closeErr) + } + _ = os.Remove(agentJar) + t.Cleanup(func() { + _ = os.Remove(agentJar) + }) jarCmd := exec.Command(jarBin, "cmf", manifestPath, agentJar, "-C", classesDir, "com") output, err = jarCmd.CombinedOutput() if err != nil { diff --git a/internal/jvm/http_provider.go b/internal/jvm/http_provider.go index b347ce5..ce71d9e 100644 --- a/internal/jvm/http_provider.go +++ b/internal/jvm/http_provider.go @@ -92,6 +92,20 @@ func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionCo return snapshot, nil } +func (p *HTTPProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + runtime, err := newEndpointRuntime(cfg) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + + var snapshot JVMMonitoringSnapshot + if err := runtime.doJSON(ctx, http.MethodGet, "get monitoring snapshot", "metrics", nil, nil, &snapshot); err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { runtime, err := newEndpointRuntime(cfg) if err != nil { diff --git a/internal/jvm/http_provider_test.go b/internal/jvm/http_provider_test.go index 2486325..d5acc6c 100644 --- a/internal/jvm/http_provider_test.go +++ b/internal/jvm/http_provider_test.go @@ -93,6 +93,52 @@ func TestHTTPProviderGetValueDecodesResponse(t *testing.T) { } } +func TestHTTPProviderGetMonitoringSnapshotDecodesResponse(t *testing.T) { + provider := &HTTPProvider{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("expected GET request, got %s", r.Method) + } + if r.URL.Path != "/manage/jvm/metrics" { + t.Fatalf("expected path /manage/jvm/metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 18, + HeapUsedBytes: 805306368, + ProcessCpuLoad: 0.48, + ProcessRssBytes: 1879048192, + LoadedClassCount: 4096, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "G1 Old Generation", + DurationMs: 41, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "cpu.process", "memory.rss", "class.loading"}, + MissingMetrics: []string{"cpu.system"}, + ProviderWarnings: []string{"endpoint cpu metric unavailable"}, + }) + })) + defer server.Close() + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newHTTPProviderTestConfig(server.URL+"/manage/jvm", 3), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if snapshot.Point.ThreadCount != 18 || snapshot.Point.HeapUsedBytes != 805306368 || snapshot.Point.ProcessRssBytes != 1879048192 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].Name != "G1 Old Generation" { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" { + t.Fatalf("unexpected missing metrics: %#v", snapshot) + } +} + func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) { provider := NewHTTPProvider() request := ChangeRequest{ diff --git a/internal/jvm/jmx_helper.go b/internal/jvm/jmx_helper.go index 40492d6..c4cca2b 100644 --- a/internal/jvm/jmx_helper.go +++ b/internal/jvm/jmx_helper.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -32,6 +33,7 @@ const ( jmxHelperCommandPing = "ping" jmxHelperCommandList = "list" jmxHelperCommandGet = "get" + jmxHelperCommandMonitor = "monitor" jmxHelperCommandPreview = "preview" jmxHelperCommandApply = "apply" @@ -44,6 +46,11 @@ var ( jmxHelperLookPath = exec.LookPath ) +var ( + jmxHelperSensitiveJSONFieldPattern = regexp.MustCompile(`(?i)("(?:password|apiKey|token|secret)"\s*:\s*")([^"]*)(")`) + jmxHelperSensitivePairPattern = regexp.MustCompile(`(?i)\b(password|api[_-]?key|token|secret)(\s*[:=]\s*)([^&\s;,"'}]+)`) +) + //go:embed jmxhelper_assets/jmx-helper-runtime.jar var embeddedJMXHelperJar []byte @@ -94,13 +101,14 @@ type jmxHelperChangePlan struct { } type jmxHelperResponse struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Details map[string]any `json:"details,omitempty"` - Resources []jmxHelperResource `json:"resources,omitempty"` - Snapshot *jmxHelperSnapshot `json:"snapshot,omitempty"` - Preview *jmxHelperPreview `json:"preview,omitempty"` - ApplyResult *jmxHelperApplyResponse `json:"applyResult,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Details map[string]any `json:"details,omitempty"` + Resources []jmxHelperResource `json:"resources,omitempty"` + Snapshot *jmxHelperSnapshot `json:"snapshot,omitempty"` + MonitoringSnapshot *jmxHelperMonitoringSnapshot `json:"monitoringSnapshot,omitempty"` + Preview *jmxHelperPreview `json:"preview,omitempty"` + ApplyResult *jmxHelperApplyResponse `json:"applyResult,omitempty"` } type jmxHelperResource struct { @@ -127,6 +135,38 @@ type jmxHelperSnapshot struct { Metadata map[string]any `json:"metadata,omitempty"` } +type jmxHelperMonitoringPoint struct { + Timestamp int64 `json:"timestamp"` + HeapUsedBytes int64 `json:"heapUsedBytes,omitempty"` + HeapCommittedBytes int64 `json:"heapCommittedBytes,omitempty"` + HeapMaxBytes int64 `json:"heapMaxBytes,omitempty"` + NonHeapUsedBytes int64 `json:"nonHeapUsedBytes,omitempty"` + NonHeapCommittedBytes int64 `json:"nonHeapCommittedBytes,omitempty"` + GCCollectionCount int64 `json:"gcCollectionCount,omitempty"` + GCCollectionTimeMs int64 `json:"gcCollectionTimeMs,omitempty"` + GCDeltaCount int64 `json:"gcDeltaCount,omitempty"` + GCDeltaTimeMs int64 `json:"gcDeltaTimeMs,omitempty"` + ThreadCount int `json:"threadCount,omitempty"` + DaemonThreadCount int `json:"daemonThreadCount,omitempty"` + PeakThreadCount int `json:"peakThreadCount,omitempty"` + ThreadStateCounts map[string]int `json:"threadStateCounts,omitempty"` + LoadedClassCount int `json:"loadedClassCount,omitempty"` + UnloadedClassCount int64 `json:"unloadedClassCount,omitempty"` + ClassLoadDelta int64 `json:"classLoadDelta,omitempty"` + ProcessCpuLoad float64 `json:"processCpuLoad,omitempty"` + SystemCpuLoad float64 `json:"systemCpuLoad,omitempty"` + ProcessRssBytes int64 `json:"processRssBytes,omitempty"` + CommittedVirtualMemoryBytes int64 `json:"committedVirtualMemoryBytes,omitempty"` +} + +type jmxHelperMonitoringSnapshot struct { + Point jmxHelperMonitoringPoint `json:"point"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + type jmxHelperPreview struct { Allowed bool `json:"allowed"` RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` @@ -366,6 +406,11 @@ func helperContextSummary(cfg connection.ConnectionConfig, target *jmxResourceTa } } +func redactJMXHelperOutput(text string) string { + redacted := jmxHelperSensitiveJSONFieldPattern.ReplaceAllString(text, `${1}${3}`) + return jmxHelperSensitivePairPattern.ReplaceAllString(redacted, `${1}${2}`) +} + func runJMXHelper( ctx context.Context, cfg connection.ConnectionConfig, @@ -414,6 +459,7 @@ func runJMXHelper( defer cancel() cmd := jmxHelperCommandContext(execCtx, runtimeInfo.javaBinary, "-cp", runtimeInfo.classpath, jmxHelperMainClass) + configureJMXHelperCommand(cmd) cmd.Stdin = bytes.NewReader(input) var stdout bytes.Buffer var stderr bytes.Buffer @@ -421,7 +467,7 @@ func runJMXHelper( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - stderrText := strings.TrimSpace(stderr.String()) + stderrText := strings.TrimSpace(redactJMXHelperOutput(stderr.String())) if stderrText == "" { stderrText = "" } @@ -436,23 +482,24 @@ func runJMXHelper( var response jmxHelperResponse if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + stdoutText := strings.TrimSpace(redactJMXHelperOutput(stdout.String())) return jmxHelperResponse{}, fmt.Errorf( "decode JMX helper %s response failed for %s: %w; stdout: %s", command, helperContextSummary(cfg, target), err, - strings.TrimSpace(stdout.String()), + stdoutText, ) } if !response.OK { - errText := strings.TrimSpace(response.Error) + errText := strings.TrimSpace(redactJMXHelperOutput(response.Error)) if errText == "" { errText = "unknown helper failure" } if len(response.Details) > 0 { detailsJSON, marshalErr := json.Marshal(response.Details) if marshalErr == nil { - errText += "; details=" + string(detailsJSON) + errText += "; details=" + redactJMXHelperOutput(string(detailsJSON)) } } return jmxHelperResponse{}, fmt.Errorf("jmx helper %s failed for %s: %s", command, helperContextSummary(cfg, target), errText) @@ -649,6 +696,42 @@ func previewFromHelper(target jmxResourceTarget, preview *jmxHelperPreview) (Cha return result, nil } +func monitoringSnapshotFromHelper(snapshot *jmxHelperMonitoringSnapshot) (JVMMonitoringSnapshot, error) { + if snapshot == nil { + return JVMMonitoringSnapshot{}, fmt.Errorf("helper did not return monitoring snapshot") + } + + return JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: snapshot.Point.Timestamp, + HeapUsedBytes: snapshot.Point.HeapUsedBytes, + HeapCommittedBytes: snapshot.Point.HeapCommittedBytes, + HeapMaxBytes: snapshot.Point.HeapMaxBytes, + NonHeapUsedBytes: snapshot.Point.NonHeapUsedBytes, + NonHeapCommittedBytes: snapshot.Point.NonHeapCommittedBytes, + GCCollectionCount: snapshot.Point.GCCollectionCount, + GCCollectionTimeMs: snapshot.Point.GCCollectionTimeMs, + GCDeltaCount: snapshot.Point.GCDeltaCount, + GCDeltaTimeMs: snapshot.Point.GCDeltaTimeMs, + ThreadCount: snapshot.Point.ThreadCount, + DaemonThreadCount: snapshot.Point.DaemonThreadCount, + PeakThreadCount: snapshot.Point.PeakThreadCount, + ThreadStateCounts: cloneStringIntMap(snapshot.Point.ThreadStateCounts), + LoadedClassCount: snapshot.Point.LoadedClassCount, + UnloadedClassCount: snapshot.Point.UnloadedClassCount, + ClassLoadDelta: snapshot.Point.ClassLoadDelta, + ProcessCpuLoad: snapshot.Point.ProcessCpuLoad, + SystemCpuLoad: snapshot.Point.SystemCpuLoad, + ProcessRssBytes: snapshot.Point.ProcessRssBytes, + CommittedVirtualMemoryBytes: snapshot.Point.CommittedVirtualMemoryBytes, + }, + RecentGCEvents: append([]RecentGCEvent(nil), snapshot.RecentGCEvents...), + AvailableMetrics: append([]string(nil), snapshot.AvailableMetrics...), + MissingMetrics: append([]string(nil), snapshot.MissingMetrics...), + ProviderWarnings: append([]string(nil), snapshot.ProviderWarnings...), + }, nil +} + func applyResultFromHelper(target jmxResourceTarget, result *jmxHelperApplyResponse) (ApplyResult, error) { if result == nil { return ApplyResult{}, fmt.Errorf("helper did not return apply result for %s", buildJMXResourcePath(target)) diff --git a/internal/jvm/jmx_helper_process_other.go b/internal/jvm/jmx_helper_process_other.go new file mode 100644 index 0000000..a7fded6 --- /dev/null +++ b/internal/jvm/jmx_helper_process_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package jvm + +import "os/exec" + +func configureJMXHelperCommand(_ *exec.Cmd) { +} diff --git a/internal/jvm/jmx_helper_process_windows.go b/internal/jvm/jmx_helper_process_windows.go new file mode 100644 index 0000000..96e72fc --- /dev/null +++ b/internal/jvm/jmx_helper_process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package jvm + +import ( + "os/exec" + "syscall" +) + +func configureJMXHelperCommand(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/jvm/jmx_helper_test.go b/internal/jvm/jmx_helper_test.go index c899794..79bf637 100644 --- a/internal/jvm/jmx_helper_test.go +++ b/internal/jvm/jmx_helper_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -82,3 +83,18 @@ func TestEnsureJMXHelperRuntimeUsesOverrideClasspath(t *testing.T) { t.Fatalf("expected override mode to skip cache writes, stat err=%v", err) } } + +func TestRedactJMXHelperOutputMasksSensitiveFields(t *testing.T) { + output := `{"password":"secret-pass","apiKey":"agent-token","details":"token=abc123 password: raw-secret"}` + + redacted := redactJMXHelperOutput(output) + + for _, secret := range []string{"secret-pass", "agent-token", "abc123", "raw-secret"} { + if strings.Contains(redacted, secret) { + t.Fatalf("expected %q to be redacted from %q", secret, redacted) + } + } + if !strings.Contains(redacted, "") { + t.Fatalf("expected redaction marker, got %q", redacted) + } +} diff --git a/internal/jvm/jmx_helper_windows_test.go b/internal/jvm/jmx_helper_windows_test.go new file mode 100644 index 0000000..d318588 --- /dev/null +++ b/internal/jvm/jmx_helper_windows_test.go @@ -0,0 +1,18 @@ +//go:build windows + +package jvm + +import ( + "os/exec" + "testing" +) + +func TestConfigureJMXHelperCommandHidesWindowOnWindows(t *testing.T) { + cmd := exec.Command("java") + + configureJMXHelperCommand(cmd) + + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.HideWindow { + t.Fatalf("expected JMX helper command to hide Windows console window, got %#v", cmd.SysProcAttr) + } +} diff --git a/internal/jvm/jmx_provider.go b/internal/jvm/jmx_provider.go index b459b59..6f39bb5 100644 --- a/internal/jvm/jmx_provider.go +++ b/internal/jvm/jmx_provider.go @@ -74,6 +74,19 @@ func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionCon return valueSnapshotFromHelper(target, resp.Snapshot) } +func (p *JMXProvider) GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + resp, err := jmxHelperRunner(ctx, cfg, jmxHelperCommandMonitor, nil, nil) + if err != nil { + return JVMMonitoringSnapshot{}, fmt.Errorf("jmx get monitoring snapshot failed: %w", err) + } + snapshot, err := monitoringSnapshotFromHelper(resp.MonitoringSnapshot) + if err != nil { + return JVMMonitoringSnapshot{}, err + } + finalizeMonitoringSnapshot(&snapshot, previous) + return snapshot, nil +} + func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { target, err := parseRequiredResourcePath(req.ResourceID) if err != nil { diff --git a/internal/jvm/jmx_provider_test.go b/internal/jvm/jmx_provider_test.go index de67b6a..45ca682 100644 --- a/internal/jvm/jmx_provider_test.go +++ b/internal/jvm/jmx_provider_test.go @@ -135,6 +135,51 @@ func TestJMXProviderGetValueUsesHelperSnapshot(t *testing.T) { } } +func TestJMXProviderGetMonitoringSnapshotUsesHelperMonitorCommand(t *testing.T) { + helper := &stubJMXHelper{ + response: jmxHelperResponse{ + MonitoringSnapshot: &jmxHelperMonitoringSnapshot{ + Point: jmxHelperMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 33, + HeapUsedBytes: 536870912, + ProcessCpuLoad: 0.37, + LoadedClassCount: 2048, + ProcessRssBytes: 1610612736, + CommittedVirtualMemoryBytes: 2147483648, + }, + RecentGCEvents: []RecentGCEvent{{ + Timestamp: 1713945600000, + Name: "G1 Young Generation", + Cause: "G1 Evacuation Pause", + DurationMs: 18, + }}, + AvailableMetrics: []string{"thread.count", "heap.used", "class.loading", "memory.rss"}, + MissingMetrics: []string{"cpu.system"}, + }, + }, + } + withStubJMXHelper(t, helper.run) + provider := &JMXProvider{} + + snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newJMXProviderTestConfig(), nil) + if err != nil { + t.Fatalf("GetMonitoringSnapshot returned error: %v", err) + } + if helper.lastRequest.Command != jmxHelperCommandMonitor { + t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandMonitor, helper.lastRequest) + } + if snapshot.Point.ThreadCount != 33 || snapshot.Point.HeapUsedBytes != 536870912 || snapshot.Point.LoadedClassCount != 2048 { + t.Fatalf("unexpected monitoring snapshot: %#v", snapshot) + } + if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].DurationMs != 18 { + t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents) + } + if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" { + t.Fatalf("unexpected missing metrics: %#v", snapshot) + } +} + func TestJMXProviderPreviewAndApplyUseHelperPayload(t *testing.T) { request := ChangeRequest{ ProviderMode: ModeJMX, diff --git a/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar b/internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar index b5aa8d51ddde49f55317f4d158b9a35e4e7f2908..396bbc6a0c76a8e5ed5351d73adbf9f0f3be7b9e 100644 GIT binary patch delta 20071 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 diff --git a/internal/jvm/monitoring_manager.go b/internal/jvm/monitoring_manager.go new file mode 100644 index 0000000..de4621c --- /dev/null +++ b/internal/jvm/monitoring_manager.go @@ -0,0 +1,351 @@ +package jvm + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" +) + +const ( + defaultMonitoringPointLimit = 180 + defaultMonitoringEventLimit = 20 + defaultMonitoringInterval = 2 * time.Second + maxMonitoringSampleFailures = 3 +) + +var monitoringProviderFactory = NewProvider + +type monitoringManager struct { + mu sync.Mutex + limit int + interval time.Duration + sessions map[string]*monitoringSession +} + +type monitoringSession struct { + mu sync.Mutex + connectionID string + providerMode string + limit int + running bool + points []JVMMonitoringPoint + recentGCEvents []RecentGCEvent + availableMetrics []string + missingMetrics []string + providerWarnings []string + cancel context.CancelFunc + generation int64 +} + +func newMonitoringManagerForTest(limit int) *monitoringManager { + return newMonitoringManager(limit, 0) +} + +func NewMonitoringManager() *monitoringManager { + return newMonitoringManager(defaultMonitoringPointLimit, defaultMonitoringInterval) +} + +func newMonitoringManager(limit int, interval time.Duration) *monitoringManager { + if limit <= 0 { + limit = defaultMonitoringPointLimit + } + return &monitoringManager{ + limit: limit, + interval: interval, + sessions: make(map[string]*monitoringSession), + } +} + +func (m *monitoringManager) ensureSession(connectionID string, providerMode string) *monitoringSession { + m.mu.Lock() + defer m.mu.Unlock() + + key := connectionID + ":" + providerMode + if session, ok := m.sessions[key]; ok { + return session + } + + session := &monitoringSession{ + connectionID: connectionID, + providerMode: providerMode, + limit: m.limit, + } + m.sessions[key] = session + return session +} + +func (m *monitoringManager) Start(ctx context.Context, raw connection.ConnectionConfig, requestedMode string) (MonitoringSessionSnapshot, error) { + cfg, providerMode, err := ResolveProviderMode(raw, requestedMode) + if err != nil { + return MonitoringSessionSnapshot{}, err + } + + connectionID := resolveMonitoringConnectionID(cfg) + session := m.ensureSession(connectionID, providerMode) + + provider, err := monitoringProviderFactory(providerMode) + if err != nil { + return MonitoringSessionSnapshot{}, err + } + + monitoringProvider, ok := provider.(MonitoringCapableProvider) + if !ok { + return MonitoringSessionSnapshot{}, fmt.Errorf("%s provider does not implement monitoring snapshot yet", ModeDisplayLabel(providerMode)) + } + + generation := session.reset(connectionID, providerMode) + if err := m.sampleOnce(ctx, monitoringProvider, cfg, session, generation); err != nil { + session.markStopped(generation) + return MonitoringSessionSnapshot{}, err + } + + session.markRunning(generation) + if m.interval > 0 { + loopCtx, cancel := context.WithCancel(context.Background()) + session.setCancel(cancel) + go m.runSampler(loopCtx, monitoringProvider, cfg, session, generation) + } + + return session.snapshot(), nil +} + +func (m *monitoringManager) Stop(connectionID string, providerMode string) error { + m.mu.Lock() + session, ok := m.sessions[m.sessionKey(connectionID, providerMode)] + m.mu.Unlock() + if !ok { + return fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode) + } + + session.stop() + return nil +} + +func (m *monitoringManager) GetHistory(connectionID string, providerMode string) (MonitoringSessionSnapshot, error) { + m.mu.Lock() + session, ok := m.sessions[m.sessionKey(connectionID, providerMode)] + m.mu.Unlock() + if !ok { + return MonitoringSessionSnapshot{}, fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode) + } + return session.snapshot(), nil +} + +func (s *monitoringSession) appendPoint(point JVMMonitoringPoint) { + s.mu.Lock() + defer s.mu.Unlock() + + s.points = append(s.points, point) + if len(s.points) > s.limit { + s.points = append([]JVMMonitoringPoint(nil), s.points[len(s.points)-s.limit:]...) + } +} + +func (m *monitoringManager) sessionKey(connectionID string, providerMode string) string { + return strings.TrimSpace(connectionID) + ":" + strings.TrimSpace(providerMode) +} + +func (m *monitoringManager) runSampler(ctx context.Context, provider MonitoringCapableProvider, cfg connection.ConnectionConfig, session *monitoringSession, generation int64) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + consecutiveFailures := 0 + + for { + select { + case <-ctx.Done(): + session.markStopped(generation) + return + case <-ticker.C: + if err := m.sampleOnce(ctx, provider, cfg, session, generation); err != nil { + consecutiveFailures++ + session.appendWarning(err.Error()) + if consecutiveFailures >= maxMonitoringSampleFailures { + session.appendWarning(fmt.Sprintf("监控采样连续失败 %d 次,已自动停止本次监控会话", consecutiveFailures)) + session.markStopped(generation) + return + } + continue + } + consecutiveFailures = 0 + } + } +} + +func (m *monitoringManager) sampleOnce(ctx context.Context, provider MonitoringCapableProvider, cfg connection.ConnectionConfig, session *monitoringSession, generation int64) error { + previous, ok := session.previousPoint(generation) + if !ok { + return nil + } + snapshot, err := provider.GetMonitoringSnapshot(ctx, cfg, previous) + if err != nil { + return err + } + session.applySnapshot(snapshot, generation) + return nil +} + +func (s *monitoringSession) snapshot() MonitoringSessionSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + + return MonitoringSessionSnapshot{ + ConnectionID: s.connectionID, + ProviderMode: s.providerMode, + Running: s.running, + Points: append([]JVMMonitoringPoint(nil), s.points...), + RecentGCEvents: append([]RecentGCEvent(nil), s.recentGCEvents...), + AvailableMetrics: append([]string(nil), s.availableMetrics...), + MissingMetrics: append([]string(nil), s.missingMetrics...), + ProviderWarnings: append([]string(nil), s.providerWarnings...), + } +} + +func (s *monitoringSession) previousPoint(generation int64) (*JVMMonitoringPoint, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if generation != s.generation { + return nil, false + } + if len(s.points) == 0 { + return nil, true + } + point := s.points[len(s.points)-1] + if point.ThreadStateCounts != nil { + point.ThreadStateCounts = cloneStringIntMap(point.ThreadStateCounts) + } + return &point, true +} + +func (s *monitoringSession) applySnapshot(snapshot JVMMonitoringSnapshot, generation int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if generation != s.generation { + return false + } + s.points = append(s.points, cloneMonitoringPoint(snapshot.Point)) + if len(s.points) > s.limit { + s.points = append([]JVMMonitoringPoint(nil), s.points[len(s.points)-s.limit:]...) + } + s.recentGCEvents = append([]RecentGCEvent(nil), snapshot.RecentGCEvents...) + if len(s.recentGCEvents) > defaultMonitoringEventLimit { + s.recentGCEvents = append([]RecentGCEvent(nil), s.recentGCEvents[len(s.recentGCEvents)-defaultMonitoringEventLimit:]...) + } + s.availableMetrics = append([]string(nil), snapshot.AvailableMetrics...) + s.missingMetrics = append([]string(nil), snapshot.MissingMetrics...) + s.providerWarnings = append([]string(nil), snapshot.ProviderWarnings...) + return true +} + +func (s *monitoringSession) appendWarning(warning string) { + s.mu.Lock() + defer s.mu.Unlock() + + trimmed := strings.TrimSpace(warning) + if trimmed == "" { + return + } + for _, existing := range s.providerWarnings { + if existing == trimmed { + return + } + } + s.providerWarnings = append(s.providerWarnings, trimmed) + if len(s.providerWarnings) > defaultMonitoringEventLimit { + s.providerWarnings = append([]string(nil), s.providerWarnings[len(s.providerWarnings)-defaultMonitoringEventLimit:]...) + } +} + +func (s *monitoringSession) reset(connectionID string, providerMode string) int64 { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + s.generation++ + s.connectionID = connectionID + s.providerMode = providerMode + s.running = false + s.points = nil + s.recentGCEvents = nil + s.availableMetrics = nil + s.missingMetrics = nil + s.providerWarnings = nil + return s.generation +} + +func (s *monitoringSession) setCancel(cancel context.CancelFunc) { + s.mu.Lock() + defer s.mu.Unlock() + s.cancel = cancel +} + +func (s *monitoringSession) markRunning(generation int64) { + s.mu.Lock() + defer s.mu.Unlock() + if generation != s.generation { + return + } + s.running = true +} + +func (s *monitoringSession) markStopped(generation int64) { + s.mu.Lock() + defer s.mu.Unlock() + if generation != s.generation { + return + } + s.running = false + s.cancel = nil +} + +func (s *monitoringSession) stop() { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.generation++ + s.running = false + s.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +func resolveMonitoringConnectionID(cfg connection.ConnectionConfig) string { + if trimmed := strings.TrimSpace(cfg.ID); trimmed != "" { + return trimmed + } + host := strings.TrimSpace(cfg.Host) + if host == "" { + host = "unknown" + } + if cfg.Port > 0 { + return fmt.Sprintf("%s:%d", host, cfg.Port) + } + return host +} + +func cloneMonitoringPoint(point JVMMonitoringPoint) JVMMonitoringPoint { + cloned := point + cloned.ThreadStateCounts = cloneStringIntMap(point.ThreadStateCounts) + return cloned +} + +func cloneStringIntMap(input map[string]int) map[string]int { + if len(input) == 0 { + return nil + } + cloned := make(map[string]int, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} diff --git a/internal/jvm/monitoring_manager_test.go b/internal/jvm/monitoring_manager_test.go new file mode 100644 index 0000000..0555d5b --- /dev/null +++ b/internal/jvm/monitoring_manager_test.go @@ -0,0 +1,353 @@ +package jvm + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "GoNavi-Wails/internal/connection" +) + +type fakeMonitoringProvider struct { + snapshot JVMMonitoringSnapshot + snapshotErr error +} + +type blockingMonitoringProvider struct { + fakeMonitoringProvider + started chan struct{} + release chan struct{} + once sync.Once +} + +func (f fakeMonitoringProvider) Mode() string { return ModeJMX } +func (f fakeMonitoringProvider) TestConnection(context.Context, connection.ConnectionConfig) error { + return nil +} +func (f fakeMonitoringProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]Capability, error) { + return nil, nil +} +func (f fakeMonitoringProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]ResourceSummary, error) { + return nil, nil +} +func (f fakeMonitoringProvider) GetValue(context.Context, connection.ConnectionConfig, string) (ValueSnapshot, error) { + return ValueSnapshot{}, nil +} +func (f fakeMonitoringProvider) PreviewChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ChangePreview, error) { + return ChangePreview{}, nil +} +func (f fakeMonitoringProvider) ApplyChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ApplyResult, error) { + return ApplyResult{}, nil +} +func (f fakeMonitoringProvider) GetMonitoringSnapshot(context.Context, connection.ConnectionConfig, *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + return f.snapshot, f.snapshotErr +} + +func (p *blockingMonitoringProvider) GetMonitoringSnapshot(context.Context, connection.ConnectionConfig, *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) { + p.once.Do(func() { + close(p.started) + }) + <-p.release + return p.snapshot, p.snapshotErr +} + +func swapMonitoringProviderFactory(factory func(mode string) (Provider, error)) func() { + prev := monitoringProviderFactory + monitoringProviderFactory = factory + return func() { monitoringProviderFactory = prev } +} + +func TestMonitoringRingBufferKeepsLatestPoints(t *testing.T) { + manager := newMonitoringManagerForTest(3) + session := manager.ensureSession("conn-1", ModeJMX) + + for i := 1; i <= 5; i++ { + session.appendPoint(JVMMonitoringPoint{Timestamp: int64(i)}) + } + + snapshot := session.snapshot() + if len(snapshot.Points) != 3 { + t.Fatalf("expected 3 points, got %d", len(snapshot.Points)) + } + if snapshot.Points[0].Timestamp != 3 || snapshot.Points[2].Timestamp != 5 { + t.Fatalf("unexpected points order: %#v", snapshot.Points) + } +} + +func TestMonitoringSessionSnapshotCarriesProviderWarningsAndGCEvents(t *testing.T) { + manager := newMonitoringManagerForTest(5) + session := manager.ensureSession("conn-2", ModeEndpoint) + session.running = true + session.availableMetrics = []string{"heap.used", "thread.count", "memory.rss"} + session.missingMetrics = []string{"cpu.process", "gc.events"} + session.providerWarnings = []string{"endpoint metrics degraded"} + session.recentGCEvents = []RecentGCEvent{ + { + Timestamp: 1713945600000, + Name: "G1 Young Generation", + Cause: "G1 Evacuation Pause", + Action: "end of minor GC", + DurationMs: 21, + BeforeUsedBytes: 734003200, + AfterUsedBytes: 503316480, + }, + } + session.appendPoint(JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 18, + HeapUsedBytes: 503316480, + ProcessRssBytes: 1073741824, + }) + + snapshot := session.snapshot() + if !snapshot.Running { + t.Fatalf("expected session to be running") + } + if snapshot.ProviderMode != ModeEndpoint { + t.Fatalf("expected provider mode %q, got %q", ModeEndpoint, snapshot.ProviderMode) + } + if len(snapshot.AvailableMetrics) != 3 { + t.Fatalf("expected available metrics, got %#v", snapshot.AvailableMetrics) + } + if len(snapshot.MissingMetrics) != 2 || snapshot.MissingMetrics[0] != "cpu.process" { + t.Fatalf("unexpected missing metrics: %#v", snapshot.MissingMetrics) + } + if len(snapshot.ProviderWarnings) != 1 { + t.Fatalf("expected provider warning, got %#v", snapshot.ProviderWarnings) + } + if len(snapshot.RecentGCEvents) != 1 { + t.Fatalf("expected recent gc event, got %#v", snapshot.RecentGCEvents) + } + if len(snapshot.Points) != 1 || snapshot.Points[0].ThreadCount != 18 || snapshot.Points[0].HeapUsedBytes != 503316480 { + t.Fatalf("unexpected points snapshot: %#v", snapshot.Points) + } +} + +func TestMonitoringManagerStartSamplesImmediatelyAndReturnsHistory(t *testing.T) { + manager := newMonitoringManagerForTest(5) + restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) { + return fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 12, + HeapUsedBytes: 268435456, + ProcessCpuLoad: 0.42, + }, + AvailableMetrics: []string{"thread.count", "heap.used"}, + MissingMetrics: []string{"cpu.process"}, + ProviderWarnings: []string{"jmx cpu metric unavailable"}, + }, + }, nil + }) + defer restore() + + readOnly := true + cfg := connection.ConnectionConfig{ + ID: "conn-monitor", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + } + + snapshot, err := manager.Start(context.Background(), cfg, "") + if err != nil { + t.Fatalf("Start returned error: %v", err) + } + if !snapshot.Running { + t.Fatalf("expected started session to be running") + } + if len(snapshot.Points) != 1 || snapshot.Points[0].ThreadCount != 12 || snapshot.Points[0].HeapUsedBytes != 268435456 { + t.Fatalf("unexpected initial points: %#v", snapshot.Points) + } + + history, err := manager.GetHistory("conn-monitor", ModeJMX) + if err != nil { + t.Fatalf("GetHistory returned error: %v", err) + } + if len(history.MissingMetrics) != 1 || history.MissingMetrics[0] != "cpu.process" { + t.Fatalf("unexpected history missing metrics: %#v", history.MissingMetrics) + } + if len(history.ProviderWarnings) != 1 { + t.Fatalf("unexpected provider warnings: %#v", history.ProviderWarnings) + } +} + +func TestMonitoringManagerStopMarksSessionStopped(t *testing.T) { + manager := newMonitoringManagerForTest(5) + restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) { + return fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{Timestamp: 1713945600000, ThreadCount: 7}, + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + ID: "conn-stop", + Type: "jvm", + Host: "orders.internal", + JVM: connection.JVMConfig{ + PreferredMode: ModeEndpoint, + AllowedModes: []string{ModeEndpoint}, + }, + } + + if _, err := manager.Start(context.Background(), cfg, ModeEndpoint); err != nil { + t.Fatalf("Start returned error: %v", err) + } + if err := manager.Stop("conn-stop", ModeEndpoint); err != nil { + t.Fatalf("Stop returned error: %v", err) + } + + history, err := manager.GetHistory("conn-stop", ModeEndpoint) + if err != nil { + t.Fatalf("GetHistory returned error: %v", err) + } + if history.Running { + t.Fatalf("expected session to stop running, got %#v", history) + } +} + +func TestMonitoringSessionIgnoresStaleStopFromPreviousSampler(t *testing.T) { + session := &monitoringSession{} + + firstGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(firstGeneration) + secondGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(secondGeneration) + + session.markStopped(firstGeneration) + if snapshot := session.snapshot(); !snapshot.Running { + t.Fatalf("expected stale sampler stop to be ignored, got %#v", snapshot) + } + + session.markStopped(secondGeneration) + if snapshot := session.snapshot(); snapshot.Running { + t.Fatalf("expected active generation stop to mark stopped, got %#v", snapshot) + } +} + +func TestMonitoringSessionIgnoresStalePointFromPreviousSampler(t *testing.T) { + manager := newMonitoringManager(5, time.Millisecond) + session := &monitoringSession{limit: 5} + provider := &blockingMonitoringProvider{ + fakeMonitoringProvider: fakeMonitoringProvider{ + snapshot: JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945600000, + ThreadCount: 8, + }, + AvailableMetrics: []string{"thread.count"}, + }, + }, + started: make(chan struct{}), + release: make(chan struct{}), + } + + firstGeneration := session.reset("conn-race", ModeJMX) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + manager.runSampler(ctx, provider, connection.ConnectionConfig{}, session, firstGeneration) + close(done) + }() + + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("sampler did not start within 1s") + } + + secondGeneration := session.reset("conn-race", ModeJMX) + session.markRunning(secondGeneration) + close(provider.release) + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("sampler did not stop within 1s") + } + + snapshot := session.snapshot() + if !snapshot.Running { + t.Fatalf("expected new generation to remain running, got %#v", snapshot) + } + if len(snapshot.Points) != 0 { + t.Fatalf("expected stale sampler point to be ignored, got %#v", snapshot.Points) + } +} + +func TestFinalizeMonitoringSnapshotPreservesProviderDeltaWhenClassTotalMissing(t *testing.T) { + snapshot := JVMMonitoringSnapshot{ + Point: JVMMonitoringPoint{ + Timestamp: 1713945602000, + ClassLoadDelta: 3, + }, + AvailableMetrics: []string{"class.delta"}, + } + + finalizeMonitoringSnapshot(&snapshot, &JVMMonitoringPoint{ + Timestamp: 1713945600000, + LoadedClassCount: 200, + }) + + if snapshot.Point.ClassLoadDelta != 3 { + t.Fatalf("expected provider class delta to be preserved, got %#v", snapshot.Point) + } +} + +func TestMonitoringSamplerStopsAfterConsecutiveFailures(t *testing.T) { + manager := newMonitoringManager(5, time.Millisecond) + session := &monitoringSession{limit: 5} + generation := session.reset("conn-fail", ModeJMX) + session.markRunning(generation) + provider := fakeMonitoringProvider{snapshotErr: errors.New("collector unavailable")} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + manager.runSampler(ctx, provider, connection.ConnectionConfig{}, session, generation) + close(done) + }() + + deadline := time.After(time.Second) + for { + select { + case <-done: + snapshot := session.snapshot() + if snapshot.Running { + t.Fatalf("expected session to stop after consecutive failures, got %#v", snapshot) + } + if len(snapshot.ProviderWarnings) == 0 { + t.Fatalf("expected provider warnings to explain sampling failure") + } + return + case <-deadline: + t.Fatal("sampler did not stop after consecutive failures") + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestMonitoringSessionDeduplicatesProviderWarnings(t *testing.T) { + session := &monitoringSession{} + + session.appendWarning("collector unavailable") + session.appendWarning("collector unavailable") + session.appendWarning(" collector unavailable ") + + snapshot := session.snapshot() + if len(snapshot.ProviderWarnings) != 1 { + t.Fatalf("expected duplicate provider warnings to be collapsed, got %#v", snapshot.ProviderWarnings) + } +} diff --git a/internal/jvm/monitoring_types.go b/internal/jvm/monitoring_types.go new file mode 100644 index 0000000..e0323ea --- /dev/null +++ b/internal/jvm/monitoring_types.go @@ -0,0 +1,90 @@ +package jvm + +import ( + "context" + + "GoNavi-Wails/internal/connection" +) + +type JVMMonitoringPoint struct { + Timestamp int64 `json:"timestamp"` + HeapUsedBytes int64 `json:"heapUsedBytes,omitempty"` + HeapCommittedBytes int64 `json:"heapCommittedBytes,omitempty"` + HeapMaxBytes int64 `json:"heapMaxBytes,omitempty"` + NonHeapUsedBytes int64 `json:"nonHeapUsedBytes,omitempty"` + NonHeapCommittedBytes int64 `json:"nonHeapCommittedBytes,omitempty"` + GCCollectionCount int64 `json:"gcCollectionCount,omitempty"` + GCCollectionTimeMs int64 `json:"gcCollectionTimeMs,omitempty"` + GCDeltaCount int64 `json:"gcDeltaCount,omitempty"` + GCDeltaTimeMs int64 `json:"gcDeltaTimeMs,omitempty"` + ThreadCount int `json:"threadCount,omitempty"` + DaemonThreadCount int `json:"daemonThreadCount,omitempty"` + PeakThreadCount int `json:"peakThreadCount,omitempty"` + ThreadStateCounts map[string]int `json:"threadStateCounts,omitempty"` + LoadedClassCount int `json:"loadedClassCount,omitempty"` + UnloadedClassCount int64 `json:"unloadedClassCount,omitempty"` + ClassLoadDelta int64 `json:"classLoadDelta,omitempty"` + ProcessCpuLoad float64 `json:"processCpuLoad,omitempty"` + SystemCpuLoad float64 `json:"systemCpuLoad,omitempty"` + ProcessRssBytes int64 `json:"processRssBytes,omitempty"` + CommittedVirtualMemoryBytes int64 `json:"committedVirtualMemoryBytes,omitempty"` +} + +type RecentGCEvent struct { + Timestamp int64 `json:"timestamp"` + Name string `json:"name,omitempty"` + Cause string `json:"cause,omitempty"` + Action string `json:"action,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + BeforeUsedBytes int64 `json:"beforeUsedBytes,omitempty"` + AfterUsedBytes int64 `json:"afterUsedBytes,omitempty"` +} + +type MonitoringSessionSnapshot struct { + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + Running bool `json:"running"` + Points []JVMMonitoringPoint `json:"points,omitempty"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + +type JVMMonitoringSnapshot struct { + Point JVMMonitoringPoint `json:"point"` + RecentGCEvents []RecentGCEvent `json:"recentGcEvents,omitempty"` + AvailableMetrics []string `json:"availableMetrics,omitempty"` + MissingMetrics []string `json:"missingMetrics,omitempty"` + ProviderWarnings []string `json:"providerWarnings,omitempty"` +} + +type MonitoringCapableProvider interface { + Provider + GetMonitoringSnapshot(ctx context.Context, cfg connection.ConnectionConfig, previous *JVMMonitoringPoint) (JVMMonitoringSnapshot, error) +} + +func finalizeMonitoringSnapshot(snapshot *JVMMonitoringSnapshot, previous *JVMMonitoringPoint) { + if snapshot == nil || previous == nil { + return + } + + if hasMonitoringMetric(snapshot.AvailableMetrics, "gc.count") && snapshot.Point.GCCollectionCount >= previous.GCCollectionCount { + snapshot.Point.GCDeltaCount = snapshot.Point.GCCollectionCount - previous.GCCollectionCount + } + if hasMonitoringMetric(snapshot.AvailableMetrics, "gc.time") && snapshot.Point.GCCollectionTimeMs >= previous.GCCollectionTimeMs { + snapshot.Point.GCDeltaTimeMs = snapshot.Point.GCCollectionTimeMs - previous.GCCollectionTimeMs + } + if hasMonitoringMetric(snapshot.AvailableMetrics, "class.loading") { + snapshot.Point.ClassLoadDelta = int64(snapshot.Point.LoadedClassCount) - int64(previous.LoadedClassCount) + } +} + +func hasMonitoringMetric(metrics []string, expected string) bool { + for _, metric := range metrics { + if metric == expected { + return true + } + } + return false +} diff --git a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java index 7124b73..46e1540 100644 --- a/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java +++ b/tools/jmx-helper/src/com/gonavi/jmxhelper/JmxRuntime.java @@ -1,5 +1,12 @@ package com.gonavi.jmxhelper; +import java.lang.management.ClassLoadingMXBean; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -46,6 +53,8 @@ final class JmxRuntime { return listResources(server, connection, target); case "get": return singleton("snapshot", getValue(server, target)); + case "monitor": + return singleton("monitoringSnapshot", getMonitoringSnapshot(server)); case "preview": return singleton("preview", previewChange(server, target, change)); case "apply": @@ -210,6 +219,208 @@ final class JmxRuntime { throw new IllegalArgumentException("unsupported target kind: " + target.kind); } + private static Map getMonitoringSnapshot(MBeanServerConnection server) throws Exception { + LinkedHashMap result = new LinkedHashMap<>(); + LinkedHashMap point = new LinkedHashMap<>(); + List availableMetrics = new ArrayList<>(); + List missingMetrics = new ArrayList<>(); + List providerWarnings = new ArrayList<>(); + + long sampleTimestamp = System.currentTimeMillis(); + point.put("timestamp", sampleTimestamp); + + try { + ThreadMXBean threadBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.THREAD_MXBEAN_NAME, + ThreadMXBean.class + ); + point.put("threadCount", threadBean.getThreadCount()); + point.put("daemonThreadCount", threadBean.getDaemonThreadCount()); + point.put("peakThreadCount", threadBean.getPeakThreadCount()); + addUnique(availableMetrics, "thread.count"); + + long[] threadIds = threadBean.getAllThreadIds(); + ThreadInfo[] infos = threadBean.getThreadInfo(threadIds, 0); + Map stateCounts = new LinkedHashMap<>(); + for (ThreadInfo info : infos) { + if (info == null || info.getThreadState() == null) { + continue; + } + String state = info.getThreadState().name(); + int current = stateCounts.get(state) instanceof Number + ? ((Number) stateCounts.get(state)).intValue() + : 0; + stateCounts.put(state, current + 1); + } + if (!stateCounts.isEmpty()) { + point.put("threadStateCounts", stateCounts); + addUnique(availableMetrics, "thread.states"); + } + } catch (Exception error) { + addUnique(missingMetrics, "thread.count"); + addUnique(providerWarnings, "thread metrics unavailable: " + error.getMessage()); + } + + try { + MemoryMXBean memoryBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.MEMORY_MXBEAN_NAME, + MemoryMXBean.class + ); + MemoryUsage heap = memoryBean.getHeapMemoryUsage(); + if (heap != null) { + point.put("heapUsedBytes", heap.getUsed()); + point.put("heapCommittedBytes", heap.getCommitted()); + point.put("heapMaxBytes", heap.getMax()); + addUnique(availableMetrics, "heap.used"); + } else { + addUnique(missingMetrics, "heap.used"); + } + + MemoryUsage nonHeap = memoryBean.getNonHeapMemoryUsage(); + if (nonHeap != null) { + point.put("nonHeapUsedBytes", nonHeap.getUsed()); + point.put("nonHeapCommittedBytes", nonHeap.getCommitted()); + addUnique(availableMetrics, "heap.non_heap"); + } + } catch (Exception error) { + addUnique(missingMetrics, "heap.used"); + addUnique(providerWarnings, "heap metrics unavailable: " + error.getMessage()); + } + + try { + ClassLoadingMXBean classLoadingBean = ManagementFactory.newPlatformMXBeanProxy( + server, + ManagementFactory.CLASS_LOADING_MXBEAN_NAME, + ClassLoadingMXBean.class + ); + point.put("loadedClassCount", classLoadingBean.getLoadedClassCount()); + point.put("unloadedClassCount", classLoadingBean.getUnloadedClassCount()); + addUnique(availableMetrics, "class.loading"); + } catch (Exception error) { + addUnique(missingMetrics, "class.loading"); + addUnique(providerWarnings, "class loading metrics unavailable: " + error.getMessage()); + } + + try { + List> recentGcEvents = new ArrayList<>(); + long totalCount = 0L; + long totalTime = 0L; + Set names = server.queryNames( + new ObjectName(ManagementFactory.GARBAGE_COLLECTOR_MXBEAN_DOMAIN_TYPE + ",*"), + null + ); + for (ObjectName name : names) { + Long collectionCount = safeLongAttribute(server, name, "CollectionCount"); + if (collectionCount != null && collectionCount >= 0L) { + totalCount += collectionCount.longValue(); + } + Long collectionTime = safeLongAttribute(server, name, "CollectionTime"); + if (collectionTime != null && collectionTime >= 0L) { + totalTime += collectionTime.longValue(); + } + + Object lastGcInfo = safeAttribute(server, name, "LastGcInfo"); + if (lastGcInfo instanceof CompositeData) { + CompositeData data = (CompositeData) lastGcInfo; + LinkedHashMap event = new LinkedHashMap<>(); + event.put("timestamp", sampleTimestamp); + event.put("name", name.getKeyProperty("name")); + Object gcCause = compositeValue(data, "GcCause"); + if (gcCause != null) { + event.put("cause", String.valueOf(gcCause)); + } + Object gcAction = compositeValue(data, "GcAction"); + if (gcAction != null) { + event.put("action", String.valueOf(gcAction)); + } + Object duration = compositeValue(data, "duration"); + if (duration instanceof Number) { + event.put("durationMs", ((Number) duration).longValue()); + } + long beforeUsedBytes = sumMemoryUsage(compositeValue(data, "memoryUsageBeforeGc")); + if (beforeUsedBytes > 0L) { + event.put("beforeUsedBytes", beforeUsedBytes); + } + long afterUsedBytes = sumMemoryUsage(compositeValue(data, "memoryUsageAfterGc")); + if (afterUsedBytes > 0L) { + event.put("afterUsedBytes", afterUsedBytes); + } + recentGcEvents.add(event); + } + } + point.put("gcCollectionCount", totalCount); + point.put("gcCollectionTimeMs", totalTime); + result.put("recentGcEvents", recentGcEvents); + addUnique(availableMetrics, "gc.count"); + addUnique(availableMetrics, "gc.time"); + if (!recentGcEvents.isEmpty()) { + addUnique(availableMetrics, "gc.events"); + } else { + addUnique(missingMetrics, "gc.events"); + } + } catch (Exception error) { + addUnique(missingMetrics, "gc.count"); + addUnique(missingMetrics, "gc.time"); + addUnique(missingMetrics, "gc.events"); + addUnique(providerWarnings, "gc metrics unavailable: " + error.getMessage()); + } + + try { + ObjectName osName = new ObjectName("java.lang:type=OperatingSystem"); + Double processCpuLoad = safeDoubleAttribute(server, osName, "ProcessCpuLoad"); + if (processCpuLoad != null && processCpuLoad >= 0d) { + point.put("processCpuLoad", processCpuLoad.doubleValue()); + addUnique(availableMetrics, "cpu.process"); + } else { + addUnique(missingMetrics, "cpu.process"); + } + + Double systemCpuLoad = safeDoubleAttribute(server, osName, "SystemCpuLoad"); + if (systemCpuLoad != null && systemCpuLoad >= 0d) { + point.put("systemCpuLoad", systemCpuLoad.doubleValue()); + addUnique(availableMetrics, "cpu.system"); + } else { + addUnique(missingMetrics, "cpu.system"); + } + + Long processRssBytes = firstNumericAttribute( + server, + osName, + "ProcessResidentMemorySize", + "ResidentSetSize", + "ResidentMemorySize" + ); + if (processRssBytes != null && processRssBytes >= 0L) { + point.put("processRssBytes", processRssBytes.longValue()); + addUnique(availableMetrics, "memory.rss"); + } else { + addUnique(missingMetrics, "memory.rss"); + } + + Long committedVirtualMemoryBytes = safeLongAttribute(server, osName, "CommittedVirtualMemorySize"); + if (committedVirtualMemoryBytes != null && committedVirtualMemoryBytes >= 0L) { + point.put("committedVirtualMemoryBytes", committedVirtualMemoryBytes.longValue()); + addUnique(availableMetrics, "memory.virtual"); + } else { + addUnique(missingMetrics, "memory.virtual"); + } + } catch (Exception error) { + addUnique(missingMetrics, "cpu.process"); + addUnique(missingMetrics, "cpu.system"); + addUnique(missingMetrics, "memory.rss"); + addUnique(missingMetrics, "memory.virtual"); + addUnique(providerWarnings, "process/system metrics unavailable: " + error.getMessage()); + } + + result.put("point", point); + result.put("availableMetrics", availableMetrics); + result.put("missingMetrics", missingMetrics); + result.put("providerWarnings", providerWarnings); + return result; + } + private static Map previewChange( MBeanServerConnection server, TargetSpec target, @@ -858,6 +1069,82 @@ final class JmxRuntime { return result; } + private static void addUnique(List items, String value) { + if (value == null || value.isEmpty() || items.contains(value)) { + return; + } + items.add(value); + } + + private static Object safeAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + try { + return server.getAttribute(objectName, attribute); + } catch (Exception error) { + return null; + } + } + + private static Long safeLongAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + Object value = safeAttribute(server, objectName, attribute); + return value instanceof Number ? ((Number) value).longValue() : null; + } + + private static Double safeDoubleAttribute(MBeanServerConnection server, ObjectName objectName, String attribute) { + Object value = safeAttribute(server, objectName, attribute); + return value instanceof Number ? ((Number) value).doubleValue() : null; + } + + private static Long firstNumericAttribute( + MBeanServerConnection server, + ObjectName objectName, + String... attributeNames + ) { + for (String attributeName : attributeNames) { + Long value = safeLongAttribute(server, objectName, attributeName); + if (value != null) { + return value; + } + } + return null; + } + + private static Object compositeValue(CompositeData data, String key) { + if (data == null || key == null || !data.getCompositeType().containsKey(key)) { + return null; + } + return data.get(key); + } + + private static long sumMemoryUsage(Object value) { + long total = 0L; + if (value instanceof TabularData) { + for (Object item : ((TabularData) value).values()) { + total += usedFromMemoryUsage(item); + } + return total; + } + if (value instanceof Map) { + for (Object item : ((Map) value).values()) { + total += usedFromMemoryUsage(item); + } + return total; + } + return usedFromMemoryUsage(value); + } + + private static long usedFromMemoryUsage(Object value) { + if (!(value instanceof CompositeData)) { + return 0L; + } + CompositeData data = (CompositeData) value; + Object used = compositeValue(data, "used"); + if (used instanceof Number) { + return ((Number) used).longValue(); + } + Object nestedValue = compositeValue(data, "value"); + return usedFromMemoryUsage(nestedValue); + } + @SuppressWarnings("unchecked") private static Map requiredObject(Object value, String label) { if (value instanceof Map) {