Skip to content

Commit bfa22b3

Browse files
Implemented .jfr recordings
1 parent 6afb6bd commit bfa22b3

3 files changed

Lines changed: 175 additions & 3 deletions

File tree

internal/agent/ondemand/ondemand.go

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ Ignored errors: %v
297297
}
298298
var nodeExtraCaptures []nodeNamedCapture
299299

300+
// JFR recording state (see the java runtime case below and the
301+
// stop/transmit step at the end of capture).
302+
var jfrEnabled bool
303+
var jfrFilePath string
304+
var jfrStartedAt time.Time
305+
300306
appRuntime := config.GetAppRuntime(pid)
301307

302308
switch appRuntime {
@@ -376,6 +382,27 @@ Ignored errors: %v
376382
// ------------------------------------------------------------------------------
377383
// Java runtime captures (default)
378384
// ------------------------------------------------------------------------------
385+
386+
// Start a JFR recording covering the whole capture window, for any
387+
// -p <pid> run. It's stopped and transmitted with dt=jfr once every
388+
// other artifact has been captured, further down.
389+
if pidPassed {
390+
jfrFilePath = capture.JFRFileName
391+
if len(dockerID) == 0 {
392+
if abs, absErr := filepath.Abs(jfrFilePath); absErr == nil {
393+
jfrFilePath = abs
394+
}
395+
}
396+
397+
if err := capture.StartJFR(pid, config.GlobalConfig.JavaHomePath, jfrFilePath); err != nil {
398+
logger.Log("WARNING: failed to start JFR recording: %s", err.Error())
399+
} else {
400+
jfrEnabled = true
401+
jfrStartedAt = time.Now()
402+
logger.Log("JFR recording started (name=%s, file=%s)", capture.JFRRecordingName, jfrFilePath)
403+
}
404+
}
405+
379406
// Capture gc
380407
gc = goCapture(endpoint, capture.WrapRun(&capture.GC{
381408
Pid: pid,
@@ -895,6 +922,31 @@ Resp: %s
895922
}
896923
logger.Log("Executed custom commands")
897924

925+
// -------------------------------
926+
// Transmit JFR recording (already auto-stopped, see StartJFR)
927+
// -------------------------------
928+
if jfrEnabled {
929+
// The recording auto-stops JFRCaptureDuration after it started (see
930+
// StartJFR), independent of how long the rest of this capture takes.
931+
// If everything else finished quickly, wait out the remainder here
932+
// so the file is guaranteed to be finalized before we read it. This
933+
// is scoped to JFR only - it does not affect the thread dump
934+
// capture above, which keeps its normal, unrelated behavior.
935+
if remaining := capture.JFRCaptureDuration - time.Since(jfrStartedAt); remaining > 0 {
936+
logger.Log("Waiting %s for JFR recording to finish before transmitting...", remaining)
937+
time.Sleep(remaining)
938+
}
939+
940+
msg, ok := capture.TransmitJFR(endpoint, jfrFilePath, dockerID)
941+
logger.Log(
942+
`JFR RECORDING DATA
943+
Is transmission completed: %t
944+
Resp: %s
945+
946+
--------------------------------
947+
`, ok, msg)
948+
}
949+
898950
if config.GlobalConfig.OnlyCapture {
899951
return
900952
}
@@ -1121,11 +1173,16 @@ func writeMetaInfo(processId int, appName, endpoint, tags string) (msg string, o
11211173
}
11221174

11231175
var ov string
1124-
osVersion, e := executils.CommandCombinedOutput(executils.OSVersion)
1176+
var osVersion bytes.Buffer
1177+
// Uses the timeout-bounded writer variant (CmdTimeout, default 60s):
1178+
// on Windows this shells out to `systeminfo`, which is known to hang
1179+
// or run very slowly on some machines, and would otherwise block the
1180+
// whole capture indefinitely.
1181+
e = executils.CommandCombinedOutputToWriter(&osVersion, executils.OSVersion)
11251182
if e != nil {
11261183
err = fmt.Errorf("osVersion err: %v, previous err: %v", e, err)
11271184
} else {
1128-
ov = strings.ReplaceAll(string(osVersion), "\r\n", ", ")
1185+
ov = strings.ReplaceAll(osVersion.String(), "\r\n", ", ")
11291186
ov = strings.ReplaceAll(ov, "\n", ", ")
11301187
}
11311188
var un string

internal/capture/jfr.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package capture
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path"
7+
"strconv"
8+
"time"
9+
10+
"yc-agent/internal/capture/executils"
11+
"yc-agent/internal/logger"
12+
)
13+
14+
// JFRRecordingName identifies the recording started by StartJFR.
15+
const JFRRecordingName = "ycJFR"
16+
17+
// JFRFileName is the fixed filename yc-agent uses for the JFR recording it
18+
// captures in -onlyCapture mode.
19+
const JFRFileName = "my.jfr"
20+
21+
// JFRCaptureDuration is how long a JFR recording runs for. It's passed as
22+
// JFR.start's own duration= option, so the JVM auto-stops the recording (and
23+
// flushes/finalizes the file) on its own - no explicit JFR.stop call needed.
24+
// The caller (ondemand.FullCapture) waits out this same duration before
25+
// calling TransmitJFR, in case the rest of the capture finishes sooner, so
26+
// the file is guaranteed to be finalized by the time it's read.
27+
const JFRCaptureDuration = 60 * time.Second
28+
29+
// StartJFR starts a JFR recording on the target JVM (pid), writing to
30+
// filePath. filePath must be an absolute path (or, for a dockerized target, a
31+
// path inside the container) since the target JVM's own working directory is
32+
// generally not the same as yc-agent's. The recording auto-stops after
33+
// JFRCaptureDuration; see TransmitJFR.
34+
func StartJFR(pid int, javaHome, filePath string) error {
35+
cmd := fmt.Sprintf("JFR.start name=%s filename=%s duration=%ds", JFRRecordingName, filePath, int(JFRCaptureDuration.Seconds()))
36+
return runJcmd(pid, javaHome, cmd)
37+
}
38+
39+
// runJcmd runs a jcmd diagnostic command against pid, trying the JDK's jcmd
40+
// binary first and falling back to jattach, then tmp jattach (mirrors
41+
// HDSub.executeJcmd).
42+
func runJcmd(pid int, javaHome, command string) error {
43+
out, err := executils.CommandCombinedOutput(
44+
executils.Command{path.Join(javaHome, "bin/jcmd"), strconv.Itoa(pid), command},
45+
executils.SudoHooker{PID: pid})
46+
if err == nil {
47+
logger.Log("jcmd %s: %s", command, out)
48+
return nil
49+
}
50+
51+
logger.Log("jcmd failed (%v), falling back to jattach for: %s", err, command)
52+
53+
out, err = executils.CommandCombinedOutput(
54+
executils.Command{executils.Executable(), "-p", strconv.Itoa(pid), "-jCmdCaptureMode", command},
55+
executils.EnvHooker{"pid": strconv.Itoa(pid)}, executils.SudoHooker{PID: pid})
56+
if err == nil {
57+
logger.Log("jattach %s: %s", command, out)
58+
return nil
59+
}
60+
61+
logger.Log("jattach failed (%v), falling back to tmp jattach for: %s", err, command)
62+
63+
tempPath, tmpErr := executils.Copy2TempPath()
64+
if tmpErr != nil {
65+
return fmt.Errorf("failed to run %q: %w (tmp jattach fallback failed: %v)", command, err, tmpErr)
66+
}
67+
68+
out, err = executils.CommandCombinedOutput(
69+
executils.Command{tempPath, "-p", strconv.Itoa(pid), "-jCmdCaptureMode", command},
70+
executils.EnvHooker{"pid": strconv.Itoa(pid)}, executils.SudoHooker{PID: pid})
71+
if err != nil {
72+
return fmt.Errorf("failed to run %q: %w, output: %s", command, err, out)
73+
}
74+
logger.Log("tmp jattach %s: %s", command, out)
75+
return nil
76+
}
77+
78+
// TransmitJFR transmits the JFR recording started by StartJFR (filePath, the
79+
// same path passed to it) to endpoint with dt=jfr. It assumes the recording
80+
// has already auto-stopped (StartJFR's duration= option) by the time this
81+
// runs. If dockerID is set, the recording is copied out of the container to
82+
// the local, relative JFRFileName first (so it also ends up in the local
83+
// capture bundle, consistent with how GC logs are handled).
84+
//
85+
// The transmission bypasses the -onlyCapture short-circuit in PostData, since
86+
// the recording needs to reach the yc-receiver regardless of -onlyCapture.
87+
func TransmitJFR(endpoint, filePath, dockerID string) (msg string, ok bool) {
88+
localPath := filePath
89+
if len(dockerID) > 0 {
90+
localPath = JFRFileName
91+
if err := DockerCopy(localPath, dockerID+":"+filePath); err != nil {
92+
return fmt.Sprintf("failed to copy JFR recording out of container: %s", err.Error()), false
93+
}
94+
}
95+
96+
file, err := os.Open(localPath)
97+
if err != nil {
98+
return fmt.Sprintf("failed to open JFR recording %s: %s", localPath, err.Error()), false
99+
}
100+
defer file.Close()
101+
102+
return PostDataForce(endpoint, "jfr", file)
103+
}

internal/capture/post.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ func PostData(endpoint, dt string, file *os.File) (msg string, ok bool) {
2929
return PostCustomData(endpoint, "dt="+dt, file)
3030
}
3131

32+
// PostDataForce behaves like PostData but always transmits over HTTP, ignoring
33+
// the -onlyCapture short-circuit. It exists for artifacts (e.g. JFR
34+
// recordings) that must reach the yc-receiver even when the rest of the
35+
// capture is only zipped locally.
36+
func PostDataForce(endpoint, dt string, file *os.File) (msg string, ok bool) {
37+
return postCustomDataWithPositionFuncWithTimeout(endpoint, "dt="+dt, file, PositionZero, config.GlobalConfig.HttpClientTimeout.Duration(), true)
38+
}
39+
3240
func PostDataWithTimeout(endpoint, dt string, file *os.File, timeout time.Duration) (msg string, ok bool) {
3341
return PostCustomDataWithTimeout(endpoint, "dt="+dt, file, timeout)
3442
}
@@ -224,7 +232,11 @@ func PostCustomDataWithPositionFunc(endpoint, params string, file *os.File, posi
224232
}
225233

226234
func PostCustomDataWithPositionFuncWithTimeout(endpoint, params string, file *os.File, position func(file *os.File) error, timeout time.Duration) (msg string, ok bool) {
227-
if config.GlobalConfig.OnlyCapture {
235+
return postCustomDataWithPositionFuncWithTimeout(endpoint, params, file, position, timeout, false)
236+
}
237+
238+
func postCustomDataWithPositionFuncWithTimeout(endpoint, params string, file *os.File, position func(file *os.File) error, timeout time.Duration, force bool) (msg string, ok bool) {
239+
if config.GlobalConfig.OnlyCapture && !force {
228240
msg = "in only capture mode"
229241
return
230242
}

0 commit comments

Comments
 (0)