diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingProfileFileTriggerConfiguration.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingProfileFileTriggerConfiguration.java
new file mode 100644
index 00000000000..b79aa02172b
--- /dev/null
+++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingProfileFileTriggerConfiguration.java
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.alerting.config;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.io.File;
+
+/**
+ * Configuration for the file-based manual profile trigger.
+ *
+ *
When enabled, the alerting subsystem periodically checks for the existence of a trigger file.
+ * If the file exists and was recently modified (within {@link #MANUAL_TRIGGER_FILE_MAX_AGE_MS}), it
+ * is deleted and a manual profile recording is initiated.
+ *
+ *
This provides an operator-friendly mechanism for triggering on-demand profiles without
+ * requiring JMX access – simply {@code touch} the trigger file from a shell or orchestration tool.
+ */
+public class AlertingProfileFileTriggerConfiguration {
+
+ /**
+ * Maximum age (in milliseconds) of the trigger file for it to be considered valid. Files older
+ * than this threshold are ignored to prevent stale trigger files from initiating unexpected
+ * recordings (e.g., after a restart).
+ */
+ public static final long MANUAL_TRIGGER_FILE_MAX_AGE_MS = 60_000; // 1 minute
+
+ // Whether the file-based manual trigger is enabled.
+ private final boolean enabled;
+
+ // Path to the file that triggers a manual profile when created/touched.
+ // If relative, it is resolved against the agent's temp directory.
+ private final File filePath;
+
+ /** The default duration (in seconds) used for profiles triggered via this file mechanism. */
+ private final int defaultProfileDurationSeconds;
+
+ private AlertingProfileFileTriggerConfiguration(
+ boolean enabled, File filePath, int defaultProfileDurationSeconds) {
+ this.enabled = enabled;
+ this.filePath = filePath;
+ this.defaultProfileDurationSeconds = defaultProfileDurationSeconds;
+ }
+
+ /**
+ * Creates a file trigger configuration, resolving relative paths against the provided temp
+ * directory.
+ *
+ * @param enabled whether file-based triggering is active
+ * @param filePath path to the trigger file (absolute or relative to {@code tempDir})
+ * @param defaultProfileDurationSeconds duration in seconds for the profile if no override is
+ * configured in the collection plan
+ * @param tempDir base directory used to resolve relative file paths
+ * @return a fully resolved configuration instance
+ */
+ @SuppressFBWarnings(
+ value = "SECPTI",
+ justification = "File path is set by trusted user configuration (applicationinsights.json)")
+ public static AlertingProfileFileTriggerConfiguration create(
+ boolean enabled, String filePath, int defaultProfileDurationSeconds, File tempDir) {
+
+ File manualTriggerFile = new File(filePath);
+ if (!manualTriggerFile.isAbsolute()) {
+ manualTriggerFile = new File(tempDir, filePath);
+ }
+
+ return new AlertingProfileFileTriggerConfiguration(
+ enabled, manualTriggerFile, defaultProfileDurationSeconds);
+ }
+
+ /** Creates a disabled configuration suitable for tests. */
+ public static AlertingProfileFileTriggerConfiguration createDefault() {
+ return new AlertingProfileFileTriggerConfiguration(
+ false, new File("applicationinsights-agent-profile-trigger"), 120);
+ }
+
+ /** Returns the default profile duration in seconds for file-triggered recordings. */
+ public int getDefaultProfileDurationSeconds() {
+ return defaultProfileDurationSeconds;
+ }
+
+ /** Returns the resolved path of the trigger file. */
+ public File getFilePath() {
+ return filePath;
+ }
+
+ /** Returns whether the file-based manual trigger is enabled. */
+ public boolean isEnabled() {
+ return enabled;
+ }
+}
diff --git a/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/AlertingSubsystem.java b/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/AlertingSubsystem.java
index f45719c797b..e5962294cab 100644
--- a/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/AlertingSubsystem.java
+++ b/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/AlertingSubsystem.java
@@ -11,9 +11,11 @@
import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration;
import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode;
import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration;
+import java.io.File;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
@@ -33,7 +35,6 @@
public class AlertingSubsystem {
private static final Logger logger = LoggerFactory.getLogger(AlertingSubsystem.class);
-
// Downstream observer of alerts produced by the alerting system
private final Consumer alertHandler;
@@ -46,25 +47,40 @@ public class AlertingSubsystem {
// Current configuration of the alerting subsystem
private AlertingConfiguration alertConfig;
- private boolean enableRequestTriggerUpdates;
+ /** Configuration controlling the file-based manual profile trigger. */
+ private final AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration;
- protected AlertingSubsystem(Consumer alertHandler) {
- this(alertHandler, TimeSource.DEFAULT, false);
- }
+ private boolean enableRequestTriggerUpdates;
protected AlertingSubsystem(
Consumer alertHandler,
TimeSource timeSource,
- boolean enableRequestTriggerUpdates) {
+ boolean enableRequestTriggerUpdates,
+ AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) {
this.alertHandler = alertHandler;
this.alertPipelines = new AlertPipelines(alertHandler);
this.timeSource = timeSource;
this.enableRequestTriggerUpdates = enableRequestTriggerUpdates;
+ this.alertingProfileFileTriggerConfiguration = alertingProfileFileTriggerConfiguration;
}
+ /**
+ * Creates and initializes an {@link AlertingSubsystem} with an initially-disabled configuration.
+ *
+ * @param alertHandler downstream consumer that handles generated alert breaches
+ * @param timeSource time source used for alert evaluation windows
+ * @param alertingProfileFileTriggerConfiguration configuration for the file-based manual trigger
+ * @return a fully initialized alerting subsystem ready to receive configuration updates
+ */
public static AlertingSubsystem create(
- Consumer alertHandler, TimeSource timeSource) {
- AlertingSubsystem alertingSubsystem = new AlertingSubsystem(alertHandler, timeSource, true);
+ Consumer alertHandler,
+ TimeSource timeSource,
+ AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) {
+
+ AlertingSubsystem alertingSubsystem =
+ new AlertingSubsystem(
+ alertHandler, timeSource, true, alertingProfileFileTriggerConfiguration);
+
// init with disabled config
alertingSubsystem.initialize(
AlertingConfiguration.create(
@@ -145,8 +161,17 @@ private void updateRequestPipelineConfig(
}
}
- /** Determine if a manual alert has been requested. */
+ /**
+ * Determine if a manual alert has been requested via any supported mechanism. Currently evaluates
+ * both the server-side collection plan and the local file-based trigger.
+ */
private void evaluateManualTrigger(AlertingConfiguration alertConfig) {
+ evaluateCollectionPlanTrigger(alertConfig);
+ evaluateFileTrigger();
+ }
+
+ /** Check if the collection plan configuration requests a manual profile. */
+ private void evaluateCollectionPlanTrigger(AlertingConfiguration alertConfig) {
CollectionPlanConfiguration config = alertConfig.getCollectionPlanConfiguration();
boolean shouldTrigger =
@@ -176,6 +201,61 @@ private void evaluateManualTrigger(AlertingConfiguration alertConfig) {
}
}
+ /**
+ * Check if a trigger file is present on the local file system and was recently modified. If so,
+ * delete the file and trigger a manual profile. The global cooldown in Profiler prevents
+ * overlapping profiles.
+ */
+ public void evaluateFileTrigger() {
+ if (!alertingProfileFileTriggerConfiguration.isEnabled()) {
+ return;
+ }
+
+ File manualTriggerFile = alertingProfileFileTriggerConfiguration.getFilePath();
+ if (manualTriggerFile == null || !manualTriggerFile.exists()) {
+ return;
+ }
+
+ long lastModified = manualTriggerFile.lastModified();
+ long age = timeSource.getNow().toEpochMilli() - lastModified;
+
+ if (age > AlertingProfileFileTriggerConfiguration.MANUAL_TRIGGER_FILE_MAX_AGE_MS) {
+ return;
+ }
+
+ // Delete the trigger file to prevent re-triggering
+ if (!manualTriggerFile.delete()) {
+ logger.warn(
+ "Failed to delete manual profile trigger file: {}", manualTriggerFile.getAbsolutePath());
+ }
+
+ logger.info("Manual profile trigger file detected, initiating profile recording");
+
+ // Use the collection plan's duration if configured, otherwise fall back to the
+ // file trigger's default duration setting.
+ CollectionPlanConfiguration collectionPlan = alertConfig.getCollectionPlanConfiguration();
+ int durationSeconds = collectionPlan.getImmediateProfilingDurationSeconds();
+ if (durationSeconds <= 0) {
+ durationSeconds = alertingProfileFileTriggerConfiguration.getDefaultProfileDurationSeconds();
+ }
+
+ AlertBreach alertBreach =
+ AlertBreach.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setAlertValue(0.0)
+ .setAlertConfiguration(
+ AlertConfiguration.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setEnabled(true)
+ .setProfileDurationSeconds(durationSeconds)
+ .build())
+ .setProfileId(UUID.randomUUID().toString())
+ .setCpuMetric(0)
+ .setMemoryUsage(0)
+ .build();
+ alertHandler.accept(alertBreach);
+ }
+
public void setPipeline(AlertMetricType type, AlertPipeline alertPipeline) {
alertPipelines.setAlertPipeline(type, alertPipeline);
}
diff --git a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemFileTriggerTest.java b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemFileTriggerTest.java
new file mode 100644
index 00000000000..51cf4a4d9a8
--- /dev/null
+++ b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemFileTriggerTest.java
@@ -0,0 +1,186 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.alerting;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
+import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
+import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration;
+import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode;
+import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration;
+import java.io.File;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class AlertingSubsystemFileTriggerTest {
+
+ @TempDir File tempDir;
+
+ private static AlertingConfiguration defaultAlertingConfig() {
+ return AlertingConfiguration.create(
+ AlertConfiguration.builder()
+ .setType(AlertMetricType.CPU)
+ .setEnabled(false)
+ .setThreshold(80.0f)
+ .setProfileDurationSeconds(30)
+ .setCooldownSeconds(14400)
+ .build(),
+ AlertConfiguration.builder()
+ .setType(AlertMetricType.MEMORY)
+ .setEnabled(false)
+ .setThreshold(20.0f)
+ .setProfileDurationSeconds(120)
+ .setCooldownSeconds(14400)
+ .build(),
+ DefaultConfiguration.builder().build(),
+ CollectionPlanConfiguration.builder()
+ .setSingle(false)
+ .setMode(EngineMode.immediate)
+ .setExpiration(Instant.now())
+ .setImmediateProfilingDurationSeconds(0)
+ .setSettingsMoniker("")
+ .build(),
+ new ArrayList<>());
+ }
+
+ @Test
+ void fileTriggerFiresWhenFileExistsAndIsRecent() throws IOException {
+ File triggerFile = new File(tempDir, "trigger");
+ assertThat(triggerFile.createNewFile()).isTrue();
+
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(true, "trigger", 120, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, new TestTimeSource(), config);
+
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNotNull();
+ assertThat(breach.get().getType()).isEqualTo(AlertMetricType.MANUAL);
+ assertThat(breach.get().getAlertConfiguration().getProfileDurationSeconds()).isEqualTo(120);
+ // trigger file should be deleted
+ assertThat(triggerFile.exists()).isFalse();
+ }
+
+ @Test
+ void fileTriggerDoesNotFireWhenDisabled() throws IOException {
+ File triggerFile = new File(tempDir, "trigger");
+ assertThat(triggerFile.createNewFile()).isTrue();
+
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(false, "trigger", 120, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, new TestTimeSource(), config);
+
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNull();
+ // file should not be deleted when disabled
+ assertThat(triggerFile.exists()).isTrue();
+ }
+
+ @Test
+ void fileTriggerDoesNotFireWhenFileDoesNotExist() {
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(true, "trigger", 120, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, new TestTimeSource(), config);
+
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNull();
+ }
+
+ @Test
+ void fileTriggerDoesNotFireWhenFileIsTooOld() throws IOException {
+ File triggerFile = new File(tempDir, "trigger");
+ assertThat(triggerFile.createNewFile()).isTrue();
+
+ // Use a time source with a known "current" time, and set the file's lastModified to be
+ // older than the max age relative to that time.
+ long currentTimeMillis = System.currentTimeMillis();
+ long oldLastModified =
+ currentTimeMillis
+ - AlertingProfileFileTriggerConfiguration.MANUAL_TRIGGER_FILE_MAX_AGE_MS
+ - 10_000;
+ assertThat(triggerFile.setLastModified(oldLastModified)).isTrue();
+
+ TestTimeSource timeSource = new TestTimeSource();
+ timeSource.setNow(Instant.ofEpochMilli(currentTimeMillis));
+
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(true, "trigger", 120, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, timeSource, config);
+
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNull();
+ // file should not be deleted when too old
+ assertThat(triggerFile.exists()).isTrue();
+ }
+
+ @Test
+ void fileTriggerDeletesFileOnSuccessfulTrigger() throws IOException {
+ File triggerFile = new File(tempDir, "trigger");
+ assertThat(triggerFile.createNewFile()).isTrue();
+
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(true, "trigger", 120, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, new TestTimeSource(), config);
+
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNotNull();
+ assertThat(triggerFile.exists()).isFalse();
+ }
+
+ @Test
+ void fileTriggerUsesDefaultDurationWhenCollectionPlanHasZero() throws IOException {
+ File triggerFile = new File(tempDir, "trigger");
+ assertThat(triggerFile.createNewFile()).isTrue();
+
+ AtomicReference breach = new AtomicReference<>();
+ Consumer consumer = breach::set;
+
+ int expectedDefaultDuration = 90;
+ AlertingProfileFileTriggerConfiguration config =
+ AlertingProfileFileTriggerConfiguration.create(
+ true, "trigger", expectedDefaultDuration, tempDir);
+
+ AlertingSubsystem subsystem = AlertingSubsystem.create(consumer, new TestTimeSource(), config);
+
+ // The default alerting config has immediateProfilingDurationSeconds=0
+ subsystem.updateConfiguration(defaultAlertingConfig());
+
+ assertThat(breach.get()).isNotNull();
+ assertThat(breach.get().getAlertConfiguration().getProfileDurationSeconds())
+ .isEqualTo(expectedDefaultDuration);
+ }
+}
diff --git a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemTest.java b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemTest.java
index 99881d032e2..eaeedc45bbd 100644
--- a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemTest.java
+++ b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/AlertingSubsystemTest.java
@@ -9,6 +9,7 @@
import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration;
import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode;
import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration;
@@ -23,7 +24,9 @@ class AlertingSubsystemTest {
private static AlertingSubsystem getAlertMonitor(
Consumer consumer, TestTimeSource timeSource) {
- AlertingSubsystem monitor = AlertingSubsystem.create(consumer, timeSource);
+ AlertingSubsystem monitor =
+ AlertingSubsystem.create(
+ consumer, timeSource, AlertingProfileFileTriggerConfiguration.createDefault());
monitor.updateConfiguration(
AlertingConfiguration.create(
@@ -82,7 +85,9 @@ void manualAlertWorks() {
Consumer consumer = called::set;
TestTimeSource timeSource = new TestTimeSource();
- AlertingSubsystem service = AlertingSubsystem.create(consumer, timeSource);
+ AlertingSubsystem service =
+ AlertingSubsystem.create(
+ consumer, timeSource, AlertingProfileFileTriggerConfiguration.createDefault());
service.updateConfiguration(
AlertingConfiguration.create(
@@ -123,7 +128,9 @@ void manualAlertDoesNotTriggerAfterExpired() {
Consumer consumer = called::set;
TestTimeSource timeSource = new TestTimeSource();
- AlertingSubsystem service = AlertingSubsystem.create(consumer, timeSource);
+ AlertingSubsystem service =
+ AlertingSubsystem.create(
+ consumer, timeSource, AlertingProfileFileTriggerConfiguration.createDefault());
service.updateConfiguration(
AlertingConfiguration.create(
diff --git a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/TestTimeSource.java b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/TestTimeSource.java
index 90d81746c91..a2d41814605 100644
--- a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/TestTimeSource.java
+++ b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/TestTimeSource.java
@@ -15,6 +15,10 @@ public Instant getNow() {
return now;
}
+ void setNow(Instant now) {
+ this.now = now;
+ }
+
void increment(int milliseconds) {
this.now = now.plusMillis(milliseconds);
}
diff --git a/agent/agent-profiler/request-triggers.md b/agent/agent-profiler/request-triggers.md
index 6a2a0876aee..538b84912aa 100644
--- a/agent/agent-profiler/request-triggers.md
+++ b/agent/agent-profiler/request-triggers.md
@@ -140,6 +140,11 @@ Currently, we support:
- `type` - Currently supports `fixed-duration-cooldown`
- `value` - Time in seconds during which a profile will not be triggered
+> **Note:** In addition to per-trigger throttling, a global cooldown (`globalCooldownSeconds`,
+> default 120s) is applied after any profile recording completes — regardless of trigger source.
+> During global cooldown, all triggers (CPU, memory, request, manual, periodic) are suppressed.
+> See [Profiler Configuration](../../docs/README.md#configuration-file) for details.
+
```json
{
"throttling": {
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/configuration/Configuration.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/configuration/Configuration.java
index 26005a97585..cb3deb0df68 100644
--- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/configuration/Configuration.java
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/configuration/Configuration.java
@@ -1531,6 +1531,38 @@ public static class ProfilerConfiguration {
public boolean enableRequestTriggering = false;
public List requestTriggerEndpoints = new ArrayList<>();
@Nullable public String cgroupPath = null;
+
+ /** Configuration for the file-based manual profile trigger mechanism. */
+ public ManualProfileTriggerConfiguration manualTrigger =
+ new ManualProfileTriggerConfiguration();
+
+ // Global cooldown in seconds applied after any profile recording completes, regardless of
+ // trigger source. During cooldown, all trigger sources (CPU, memory, request, manual, periodic)
+ // are suppressed. Set to 0 to disable (individual trigger cooldowns still apply).
+ public int globalCooldownSeconds = 120;
+
+ // Whether to register a JMX MBean that allows triggering profiles via JMX tools.
+ public boolean enableProfilerControlMBean = false;
+ }
+
+ /**
+ * Configuration for the file-based manual profile trigger.
+ *
+ * When enabled, the agent watches for the creation of a trigger file. Touching or creating the
+ * file initiates an on-demand profile recording. The file is deleted after the trigger is
+ * processed to prevent repeated recordings.
+ */
+ public static class ManualProfileTriggerConfiguration {
+ // Whether the file-based manual trigger is enabled.
+ public boolean enabled = true;
+
+ // Path to the file that triggers a manual profile when created/touched.
+ // If relative, it is resolved against the agent's temp directory.
+ public String filePath = "applicationinsights-agent-profile-trigger";
+
+ // Default duration (in seconds) for profiles initiated by the file trigger when no override
+ // is specified in the collection plan configuration.
+ public int defaultProfileDurationSeconds = 120;
}
public static class GcEventConfiguration {
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/PerformanceMonitoringService.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/PerformanceMonitoringService.java
index 8a4860ab594..d8af15d22db 100644
--- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/PerformanceMonitoringService.java
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/PerformanceMonitoringService.java
@@ -15,6 +15,7 @@
import com.microsoft.applicationinsights.agent.internal.telemetry.TelemetryObservers;
import com.microsoft.applicationinsights.alerting.AlertingSubsystem;
import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import com.microsoft.applicationinsights.diagnostics.DiagnosticEngine;
import com.microsoft.applicationinsights.diagnostics.DiagnosticEngineFactory;
import com.microsoft.applicationinsights.diagnostics.appinsights.CodeOptimizerApplicationInsightFactoryJfr;
@@ -102,6 +103,14 @@ synchronized void enableProfiler(
profiler = new Profiler(configuration, tempDir);
+ // Build file-trigger configuration, resolving relative paths against the agent's temp directory
+ AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration =
+ AlertingProfileFileTriggerConfiguration.create(
+ configuration.manualTrigger.enabled,
+ configuration.manualTrigger.filePath,
+ configuration.manualTrigger.defaultProfileDurationSeconds,
+ tempDir);
+
alerting =
AlertingSubsystemInit.create(
configuration,
@@ -110,7 +119,8 @@ synchronized void enableProfiler(
profiler,
telemetryClient,
diagnosticEngine,
- alertServiceExecutorService);
+ alertServiceExecutorService,
+ alertingProfileFileTriggerConfiguration);
uploadService =
new UploadService(
@@ -191,4 +201,10 @@ public void updateConfiguration(AlertingConfiguration alertingConfig) {
alerting.updateConfiguration(alertingConfig);
}
}
+
+ public void evaluateFileTrigger() {
+ if (alerting != null) {
+ alerting.evaluateFileTrigger();
+ }
+ }
}
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/Profiler.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/Profiler.java
index 5e877d9f1fb..6ad09d39bbb 100644
--- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/Profiler.java
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/Profiler.java
@@ -8,6 +8,7 @@
import com.microsoft.applicationinsights.agent.internal.profiler.upload.UploadListener;
import com.microsoft.applicationinsights.agent.internal.profiler.upload.UploadService;
import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
+import com.microsoft.applicationinsights.alerting.analysis.TimeSource;
import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
import io.opentelemetry.contrib.jfr.connection.FlightRecorderConnection;
@@ -39,6 +40,8 @@
*
* - Instantiates FlightRecorder subsystem
*
- Creates profiles on demand
+ *
- Enforces a global cooldown between recordings to prevent rapid successive profiles from
+ * different trigger sources (CPU, memory, request, manual, periodic)
*
*/
public class Profiler {
@@ -60,6 +63,15 @@ public class Profiler {
@Nullable private Recording activeRecording = null;
@Nullable private File activeRecordingFile = null;
+ // Global cooldown: earliest time at which a new recording is allowed, regardless of trigger type.
+ // This prevents rapid successive profiles from different trigger sources (e.g., file trigger
+ // immediately followed by a CPU threshold breach). Reset after each recording completes.
+ private volatile Instant globalCooldownUntil = Instant.MIN;
+
+ // Duration (in seconds) of the global cooldown period. A value of 0 disables the global cooldown
+ // (individual per-trigger cooldowns still apply).
+ private final int globalCooldownSeconds;
+
private final RecordingConfiguration memoryRecordingConfiguration;
private final RecordingConfiguration cpuRecordingConfiguration;
private final RecordingConfiguration spanRecordingConfiguration;
@@ -67,7 +79,15 @@ public class Profiler {
private final File temporaryDirectory;
+ private final TimeSource timeSource;
+
public Profiler(Configuration.ProfilerConfiguration config, File tempDir) {
+ this(config, tempDir, TimeSource.DEFAULT);
+ }
+
+ public Profiler(Configuration.ProfilerConfiguration config, File tempDir, TimeSource timeSource) {
+
+ this.timeSource = timeSource;
periodicConfig =
AlertConfiguration.builder()
@@ -78,6 +98,8 @@ public Profiler(Configuration.ProfilerConfiguration config, File tempDir) {
.setCooldownSeconds(config.periodicRecordingIntervalSeconds)
.build();
+ globalCooldownSeconds = config.globalCooldownSeconds;
+
memoryRecordingConfiguration = AlternativeJfrConfigurations.getMemoryProfileConfig(config);
cpuRecordingConfiguration = AlternativeJfrConfigurations.getCpuProfileConfig(config);
spanRecordingConfiguration = AlternativeJfrConfigurations.getSpanProfileConfig(config);
@@ -109,6 +131,17 @@ public void initialize(
}
}
+ // visible for testing
+ void initialize(
+ UploadService uploadService,
+ ScheduledExecutorService scheduledExecutorService,
+ FlightRecorderConnection flightRecorderConnection) {
+ this.uploadService = uploadService;
+ this.scheduledExecutorService = scheduledExecutorService;
+ this.recordingOptionsBuilder = new RecordingOptions.Builder();
+ this.flightRecorderConnection = flightRecorderConnection;
+ }
+
/** Apply new configuration settings obtained from Service Profiler. */
public void updateConfiguration(ProfilerConfiguration newConfig) {
logger.debug("Received config {}", newConfig.getLastModified());
@@ -118,7 +151,7 @@ public void updateConfiguration(ProfilerConfiguration newConfig) {
// visible for tests
void profileAndUpload(AlertBreach alertBreach, Duration duration, UploadListener uploadListener) {
- Instant recordingStart = Instant.now();
+ Instant recordingStart = timeSource.getNow();
executeProfile(
alertBreach.getType(),
duration,
@@ -133,6 +166,15 @@ private Recording startRecording(AlertMetricType alertType, Duration duration) {
return null;
}
+ // Enforce global cooldown across all trigger sources
+ if (globalCooldownSeconds > 0 && timeSource.getNow().isBefore(globalCooldownUntil)) {
+ logger.info(
+ "Alert received (type={}), but global cooldown is active until {}. Ignoring request.",
+ alertType,
+ globalCooldownUntil);
+ return null;
+ }
+
RecordingConfiguration recordingConfiguration;
switch (alertType) {
case REQUEST:
@@ -278,10 +320,17 @@ private static void writeFileFromStream(Recording recording, File recordingFile)
}
}
- private void clearActiveRecording() {
+ // visible for testing
+ void clearActiveRecording() {
synchronized (activeRecordingLock) {
activeRecording = null;
+ // Start global cooldown now that the recording is complete
+ if (globalCooldownSeconds > 0) {
+ globalCooldownUntil = timeSource.getNow().plusSeconds(globalCooldownSeconds);
+ logger.debug("Global profile cooldown active until {}", globalCooldownUntil);
+ }
+
// delete uploaded profile
if (activeRecordingFile != null && activeRecordingFile.exists()) {
if (!activeRecordingFile.delete()) {
@@ -292,6 +341,18 @@ private void clearActiveRecording() {
}
}
+ // visible for testing
+ Instant getGlobalCooldownUntil() {
+ return globalCooldownUntil;
+ }
+
+ // visible for testing
+ boolean isRecordingActive() {
+ synchronized (activeRecordingLock) {
+ return activeRecording != null;
+ }
+ }
+
/** Dump JFR profile to file. */
// visible for testing
protected File createJfrFile(Duration duration) throws IOException {
@@ -302,7 +363,7 @@ protected File createJfrFile(Duration duration) throws IOException {
}
}
- Instant recordingStart = Instant.now();
+ Instant recordingStart = timeSource.getNow();
Instant recordingEnd = recordingStart.plus(duration);
return new File(
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControl.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControl.java
new file mode 100644
index 00000000000..2f871266913
--- /dev/null
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControl.java
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.agent.internal.profiler;
+
+import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
+import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import java.lang.management.ManagementFactory;
+import java.util.UUID;
+import java.util.function.Consumer;
+import javax.management.InstanceAlreadyExistsException;
+import javax.management.MBeanServer;
+import javax.management.ObjectName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * JMX MBean that exposes profile triggering via JMX tools.
+ *
+ * Usage via jmxterm (any JDK):
+ *
+ *
+ * echo "run -b com.microsoft:type=AI-alert,name=ProfilerControl triggerProfile" | \
+ * java -jar jmxterm.jar -l <pid>
+ *
+ *
+ * Or connect with JConsole and invoke {@code triggerProfile()} on the {@code
+ * com.microsoft:type=AI-alert,name=ProfilerControl} MBean.
+ */
+public class ProfilerControl implements ProfilerControlMBean {
+
+ private static final Logger logger = LoggerFactory.getLogger(ProfilerControl.class);
+
+ private static final String OBJECT_NAME = "com.microsoft:type=AI-alert,name=ProfilerControl";
+ private static final int DEFAULT_DURATION_SECONDS = 120;
+
+ private final Consumer alertHandler;
+
+ /**
+ * Creates a new ProfilerControl instance.
+ *
+ * @param alertHandler consumer that processes the generated {@link AlertBreach}, typically wired
+ * to the profiler's recording logic
+ */
+ ProfilerControl(Consumer alertHandler) {
+ this.alertHandler = alertHandler;
+ }
+
+ @Override
+ public String triggerProfile() {
+ return triggerProfile(DEFAULT_DURATION_SECONDS);
+ }
+
+ @Override
+ public String triggerProfile(int durationSeconds) {
+ if (durationSeconds <= 0) {
+ return "Error: duration must be positive, got " + durationSeconds;
+ }
+
+ logger.info("Manual profile triggered via JMX, duration={}s", durationSeconds);
+
+ AlertBreach alertBreach =
+ AlertBreach.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setAlertValue(0.0)
+ .setAlertConfiguration(
+ AlertConfiguration.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setEnabled(true)
+ .setProfileDurationSeconds(durationSeconds)
+ .build())
+ .setProfileId(UUID.randomUUID().toString())
+ .setCpuMetric(0)
+ .setMemoryUsage(0)
+ .build();
+
+ alertHandler.accept(alertBreach);
+ return "Profile recording started (duration="
+ + durationSeconds
+ + "s, id="
+ + alertBreach.getProfileId()
+ + ")";
+ }
+
+ /**
+ * Registers this MBean with the platform MBean server. Call during profiler initialization.
+ *
+ * @param alertHandler the alert handler (typically wired to Profiler.accept)
+ */
+ public static void register(Consumer alertHandler) {
+ try {
+ MBeanServer beanServer = ManagementFactory.getPlatformMBeanServer();
+ ObjectName objectName = new ObjectName(OBJECT_NAME);
+ ProfilerControl bean = new ProfilerControl(alertHandler);
+ beanServer.registerMBean(bean, objectName);
+ logger.info(
+ "Registered profiler control MBean: {}. "
+ + "Trigger profiles via JMX tools (e.g. jmxterm or JConsole).",
+ OBJECT_NAME);
+ } catch (InstanceAlreadyExistsException e) {
+ logger.debug("Profiler control MBean already registered");
+ } catch (Exception e) {
+ logger.warn("Failed to register profiler control MBean", e);
+ }
+ }
+}
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlMBean.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlMBean.java
new file mode 100644
index 00000000000..7e807164d2a
--- /dev/null
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlMBean.java
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.agent.internal.profiler;
+
+/**
+ * MBean interface for triggering Application Insights profiles via JMX.
+ *
+ * Can be invoked via jmxterm, JConsole, or any JMX-compatible tool. The MBean is registered
+ * under {@code com.microsoft:type=AI-alert,name=ProfilerControl} when {@code
+ * enableProfilerControlMBean} is set to {@code true} in the profiler configuration.
+ *
+ * @see ProfilerControl
+ */
+public interface ProfilerControlMBean {
+
+ /**
+ * Trigger a manual profile with the default duration (120 seconds).
+ *
+ * @return a status message indicating whether the profile was started
+ */
+ String triggerProfile();
+
+ /**
+ * Trigger a manual profile with the specified duration.
+ *
+ * @param durationSeconds the desired recording duration in seconds; must be positive
+ * @return a status message indicating whether the profile was started
+ */
+ String triggerProfile(int durationSeconds);
+}
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializer.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializer.java
index b6fac1be160..1c500dce34b 100644
--- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializer.java
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializer.java
@@ -157,12 +157,22 @@ private void startPollingForConfigUpdates() {
private void pullProfilerSettings(ConfigService configService) {
try {
- configService.pullSettings().subscribe(this::applyConfiguration, this::logProfilerPullError);
+ configService.pullSettings()
+ .doFinally(result -> evaluateFileTrigger())
+ .subscribe(this::applyConfiguration, this::logProfilerPullError);
} catch (Throwable t) {
logProfilerPullError(t);
}
}
+ private void evaluateFileTrigger() {
+ if (currentlyEnabled.get()) {
+ if (performanceMonitoringService != null) {
+ performanceMonitoringService.evaluateFileTrigger();
+ }
+ }
+ }
+
private void logProfilerPullError(Throwable e) {
if (currentlyEnabled.get()) {
logger.error("Error pulling service profiler settings", e);
diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertingSubsystemInit.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertingSubsystemInit.java
index 51f696750f9..b7c5b3a5b85 100644
--- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertingSubsystemInit.java
+++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertingSubsystemInit.java
@@ -14,6 +14,7 @@
import com.microsoft.applicationinsights.agent.internal.configuration.Configuration;
import com.microsoft.applicationinsights.agent.internal.configuration.GcReportingLevel;
import com.microsoft.applicationinsights.agent.internal.profiler.Profiler;
+import com.microsoft.applicationinsights.agent.internal.profiler.ProfilerControl;
import com.microsoft.applicationinsights.agent.internal.profiler.upload.ServiceProfilerIndex;
import com.microsoft.applicationinsights.agent.internal.telemetry.TelemetryClient;
import com.microsoft.applicationinsights.agent.internal.telemetry.TelemetryObservers;
@@ -23,6 +24,7 @@
import com.microsoft.applicationinsights.alerting.analysis.pipelines.AlertPipeline;
import com.microsoft.applicationinsights.alerting.analysis.pipelines.AlertPipelineMultiplexer;
import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import com.microsoft.applicationinsights.diagnostics.DiagnosticEngine;
import java.util.List;
import java.util.Map;
@@ -47,14 +49,17 @@ public static AlertingSubsystem create(
Profiler profiler,
TelemetryClient telemetryClient,
DiagnosticEngine diagnosticEngine,
- ExecutorService executorService) {
+ ExecutorService executorService,
+ AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) {
// TODO (trask) delay creation of AlertingSubsystem until after Profiler is created and
// initialized?
Consumer alertAction =
alert -> alertAction(alert, profiler, diagnosticEngine, telemetryClient);
- alertingSubsystem = AlertingSubsystem.create(alertAction, TimeSource.DEFAULT);
+ alertingSubsystem =
+ AlertingSubsystem.create(
+ alertAction, TimeSource.DEFAULT, alertingProfileFileTriggerConfiguration);
if (configuration.enableRequestTriggering) {
if (!configuration.requestTriggerEndpoints.isEmpty()) {
@@ -80,6 +85,11 @@ public static AlertingSubsystem create(
executorService,
fromGcEventMonitorConfiguration(reportingLevel));
+ // Register JMX MBean for triggering profiles via JMX tools (e.g. jmxterm, JConsole)
+ if (configuration.enableProfilerControlMBean) {
+ ProfilerControl.register(alertAction);
+ }
+
return alertingSubsystem;
}
diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlTest.java
new file mode 100644
index 00000000000..229affa8ae8
--- /dev/null
+++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerControlTest.java
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.agent.internal.profiler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
+import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import java.lang.management.ManagementFactory;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import javax.management.MBeanServer;
+import javax.management.ObjectName;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class ProfilerControlTest {
+
+ private static final String OBJECT_NAME = "com.microsoft:type=AI-alert,name=ProfilerControl";
+
+ @AfterEach
+ void cleanup() throws Exception {
+ // Unregister MBean if it was registered during the test
+ MBeanServer beanServer = ManagementFactory.getPlatformMBeanServer();
+ ObjectName objectName = new ObjectName(OBJECT_NAME);
+ if (beanServer.isRegistered(objectName)) {
+ beanServer.unregisterMBean(objectName);
+ }
+ }
+
+ @Test
+ void triggerProfileWithDefaultDuration() {
+ AtomicReference received = new AtomicReference<>();
+ Consumer handler = received::set;
+
+ ProfilerControl control = new ProfilerControl(handler);
+ String result = control.triggerProfile();
+
+ assertThat(result).startsWith("Profile recording started");
+ assertThat(result).contains("duration=120s");
+
+ AlertBreach breach = received.get();
+ assertThat(breach).isNotNull();
+ assertThat(breach.getType()).isEqualTo(AlertMetricType.MANUAL);
+ assertThat(breach.getAlertConfiguration().getProfileDurationSeconds()).isEqualTo(120);
+ assertThat(breach.getProfileId()).isNotNull();
+ }
+
+ @Test
+ void registerCreatesAccessibleMBean() throws Exception {
+ AtomicReference received = new AtomicReference<>();
+ Consumer handler = received::set;
+
+ ProfilerControl.register(handler);
+
+ MBeanServer beanServer = ManagementFactory.getPlatformMBeanServer();
+ ObjectName objectName = new ObjectName(OBJECT_NAME);
+
+ assertThat(beanServer.isRegistered(objectName)).isTrue();
+
+ // Invoke triggerProfile via JMX
+ Object result = beanServer.invoke(objectName, "triggerProfile", null, null);
+
+ assertThat(result).isInstanceOf(String.class);
+ assertThat((String) result).contains("duration=120s");
+ assertThat(received.get()).isNotNull();
+ assertThat(received.get().getType()).isEqualTo(AlertMetricType.MANUAL);
+ }
+
+ @Test
+ void registerWithCustomDurationViaMBean() throws Exception {
+ AtomicReference received = new AtomicReference<>();
+ Consumer handler = received::set;
+
+ ProfilerControl.register(handler);
+
+ MBeanServer beanServer = ManagementFactory.getPlatformMBeanServer();
+ ObjectName objectName = new ObjectName(OBJECT_NAME);
+
+ Object result =
+ beanServer.invoke(objectName, "triggerProfile", new Object[] {45}, new String[] {"int"});
+
+ assertThat(result).isInstanceOf(String.class);
+ assertThat((String) result).contains("duration=45s");
+ assertThat(received.get()).isNotNull();
+ assertThat(received.get().getAlertConfiguration().getProfileDurationSeconds()).isEqualTo(45);
+ }
+
+ @Test
+ void registerDoesNotThrowOnDoubleRegistration() {
+ Consumer handler = breach -> {};
+
+ ProfilerControl.register(handler);
+ // Should not throw
+ ProfilerControl.register(handler);
+ }
+}
diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerGlobalCooldownTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerGlobalCooldownTest.java
new file mode 100644
index 00000000000..d034fe93618
--- /dev/null
+++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerGlobalCooldownTest.java
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.applicationinsights.agent.internal.profiler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.microsoft.applicationinsights.agent.internal.configuration.Configuration;
+import com.microsoft.applicationinsights.agent.internal.profiler.testutil.TestTimeSource;
+import com.microsoft.applicationinsights.agent.internal.profiler.upload.UploadListener;
+import com.microsoft.applicationinsights.agent.internal.profiler.upload.UploadService;
+import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
+import com.microsoft.applicationinsights.alerting.config.AlertConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import io.opentelemetry.contrib.jfr.connection.FlightRecorderConnection;
+import io.opentelemetry.contrib.jfr.connection.Recording;
+import io.opentelemetry.contrib.jfr.connection.RecordingConfiguration;
+import io.opentelemetry.contrib.jfr.connection.RecordingOptions;
+import java.io.File;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.UUID;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ProfilerGlobalCooldownTest {
+ @TempDir File tempDir;
+
+ private final TestTimeSource timeSource = new TestTimeSource();
+
+ private static AlertBreach manualBreach(int durationSeconds) {
+ return AlertBreach.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setAlertValue(0.0)
+ .setAlertConfiguration(
+ AlertConfiguration.builder()
+ .setType(AlertMetricType.MANUAL)
+ .setEnabled(true)
+ .setProfileDurationSeconds(durationSeconds)
+ .build())
+ .setProfileId(UUID.randomUUID().toString())
+ .setCpuMetric(0)
+ .setMemoryUsage(0)
+ .build();
+ }
+
+ private Profiler createProfiler(int globalCooldownSeconds) {
+ Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration();
+ config.globalCooldownSeconds = globalCooldownSeconds;
+ Profiler profiler =
+ new Profiler(config, tempDir, timeSource) {
+ @Override
+ protected Recording createRecording(RecordingOptions opts, RecordingConfiguration cfg) {
+ return mock(Recording.class);
+ }
+ };
+
+ FlightRecorderConnection frc = mock(FlightRecorderConnection.class);
+ when(frc.newRecording(any(), any())).thenReturn(mock(Recording.class));
+
+ profiler.initialize(mock(UploadService.class), Executors.newScheduledThreadPool(1), frc);
+ return profiler;
+ }
+
+ @Test
+ void globalCooldownIsSetAfterRecordingCompletes() {
+ Instant baseTime = Instant.parse("2025-01-01T00:00:00Z");
+ timeSource.setNow(baseTime);
+
+ Profiler profiler = createProfiler(120);
+ UploadListener noOpListener = index -> {};
+ profiler.profileAndUpload(manualBreach(1), Duration.ofSeconds(1), noOpListener);
+ // Before clearing, cooldown should still be at MIN
+ assertThat(profiler.getGlobalCooldownUntil()).isEqualTo(Instant.MIN);
+ profiler.clearActiveRecording();
+ // After clearing, cooldown should be exactly baseTime + 120s
+ assertThat(profiler.getGlobalCooldownUntil()).isEqualTo(baseTime.plusSeconds(120));
+ }
+
+ @Test
+ void globalCooldownNotSetWhenDisabled() {
+ timeSource.setNow(Instant.parse("2025-01-01T00:00:00Z"));
+
+ Profiler profiler = createProfiler(0);
+ UploadListener noOpListener = index -> {};
+ profiler.profileAndUpload(manualBreach(1), Duration.ofSeconds(1), noOpListener);
+ profiler.clearActiveRecording();
+ assertThat(profiler.getGlobalCooldownUntil()).isEqualTo(Instant.MIN);
+ }
+
+ @Test
+ void secondProfileRejectedDuringCooldown() {
+ Instant baseTime = Instant.parse("2025-01-01T00:00:00Z");
+ timeSource.setNow(baseTime);
+
+ Profiler profiler = createProfiler(600);
+ UploadListener noOpListener = index -> {};
+ // First profile starts and completes
+ profiler.profileAndUpload(manualBreach(1), Duration.ofSeconds(1), noOpListener);
+ profiler.clearActiveRecording();
+ // Cooldown should now be active (baseTime + 600s)
+ assertThat(profiler.getGlobalCooldownUntil()).isEqualTo(baseTime.plusSeconds(600));
+
+ // Advance time but stay within cooldown window
+ timeSource.setNow(baseTime.plusSeconds(300));
+
+ // Second profile should be silently rejected (startRecording returns null due to cooldown)
+ profiler.profileAndUpload(manualBreach(1), Duration.ofSeconds(1), noOpListener);
+ // activeRecording should still be null (second profile was rejected)
+ assertThat(profiler.isRecordingActive()).isFalse();
+ }
+}
diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertTriggerSpanProcessorTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertTriggerSpanProcessorTest.java
index 62747ce97a1..06004846bd5 100644
--- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertTriggerSpanProcessorTest.java
+++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertTriggerSpanProcessorTest.java
@@ -10,6 +10,7 @@
import com.microsoft.applicationinsights.alerting.analysis.TimeSource;
import com.microsoft.applicationinsights.alerting.analysis.pipelines.AlertPipelineMultiplexer;
import com.microsoft.applicationinsights.alerting.config.AlertMetricType;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
@@ -121,7 +122,11 @@ private static void run(Handle handle) throws InterruptedException {
triggerConfig.filter.value = "foo.*";
triggerConfig.threshold.value = 0.75f;
- AlertingSubsystem alertingSubsystem = AlertingSubsystem.create(alertAction, TimeSource.DEFAULT);
+ AlertingSubsystem alertingSubsystem =
+ AlertingSubsystem.create(
+ alertAction,
+ TimeSource.DEFAULT,
+ AlertingProfileFileTriggerConfiguration.createDefault());
TestTimeSource timeSource = new TestTimeSource();
timeSource.setNow(Instant.EPOCH);
diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/GcEventInitTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/GcEventInitTest.java
index 667d1ce7418..5b1257d5f16 100644
--- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/GcEventInitTest.java
+++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/GcEventInitTest.java
@@ -12,6 +12,7 @@
import com.microsoft.applicationinsights.alerting.alert.AlertBreach;
import com.microsoft.applicationinsights.alerting.analysis.TimeSource;
import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration;
+import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration;
import com.microsoft.gcmonitor.GcCollectionEvent;
import com.microsoft.gcmonitor.GcEventConsumer;
import com.microsoft.gcmonitor.GcMonitorFactory;
@@ -73,7 +74,10 @@ public MemoryManagement monitor(
private static AlertingSubsystem getAlertingSubsystem(
CompletableFuture alertFuture, TimeSource timeSource) {
AlertingSubsystem alertingSubsystem =
- AlertingSubsystem.create(alertFuture::complete, timeSource);
+ AlertingSubsystem.create(
+ alertFuture::complete,
+ timeSource,
+ AlertingProfileFileTriggerConfiguration.createDefault());
AlertingConfiguration config =
AlertConfigParser.parse(
diff --git a/docs/README.md b/docs/README.md
index 8fb8eddc3c2..4df538708a3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -92,7 +92,15 @@ Additionally, a number of parameters can be configured using environment variabl
"profiler": {
"enabled": true,
"cpuTriggeredSettings": "profile-without-env-data",
- "memoryTriggeredSettings": "profile-without-env-data"
+ "memoryTriggeredSettings": "profile-without-env-data",
+ "manualTriggeredSettings": "profile-without-env-data",
+ "globalCooldownSeconds": 120,
+ "enableProfilerControlMBean": false,
+ "manualTrigger": {
+ "enabled": true,
+ "filePath": "applicationinsights-agent-profile-trigger",
+ "defaultProfileDurationSeconds": 120
+ }
}
}
}
@@ -114,3 +122,65 @@ This can be one of:
[Warning](#Warning) section for details.
- `profile`. Uses the `profile.jfc` jfc configuration that ships with JFR.
- A path to a custom jfc configuration file on the file system, i.e `/tmp/myconfig.jfc`.
+
+`manualTriggeredSettings` - This configuration will be used for manually triggered profiles (via
+file trigger or JMX MBean). Accepts the same values as `cpuTriggeredSettings`.
+
+`globalCooldownSeconds` - (default: 120) Cooldown period in seconds applied after any profile
+recording completes, regardless of trigger source. During cooldown, all trigger sources (CPU,
+memory, request, manual, periodic) are suppressed. Set to `0` to disable (individual trigger
+cooldowns still apply).
+
+`enableProfilerControlMBean` - (default: false) Whether to register a JMX MBean
+(`com.microsoft:type=AI-alert,name=ProfilerControl`) that allows triggering profiles via
+JMX tools. See [Manual Profile Triggering](#manual-profile-triggering) for usage.
+
+`manualTrigger` - Configuration for the file-based manual profile trigger:
+
+- `enabled` - (default: true) Whether the file-based manual trigger is enabled.
+- `filePath` - (default: `applicationinsights-agent-profile-trigger`) Path to the trigger file.
+ If relative, it is resolved against the agent's temp directory. Creating or touching this file
+ triggers a profile recording.
+- `defaultProfileDurationSeconds` - (default: 120) Duration in seconds for profiles initiated by
+ the file trigger when no override is specified in the collection plan.
+
+## Manual Profile Triggering
+
+In addition to automatic threshold-based triggers, profiles can be initiated manually using either
+the file-based trigger or the JMX MBean.
+
+### File-based trigger
+
+When `manualTrigger.enabled` is `true` (the default), you can trigger a profile by creating or
+touching the trigger file:
+
+```bash
+touch /tmp/applicationinsights-agent-profile-trigger
+```
+
+The file must have been modified within the last 60 seconds to be considered valid (stale files
+are ignored to prevent unintended recordings after restarts). After the trigger is detected, the
+file is automatically deleted.
+
+> **Note:** The file trigger is evaluated on the profiler's configuration polling cycle
+> (default every 60 seconds), so there may be up to a one-minute delay between touching the file
+> and the profile recording starting.
+
+### JMX MBean trigger
+
+When `enableProfilerControlMBean` is `true`, the agent registers a JMX MBean that can be invoked
+to trigger profiles:
+
+**Via jmxterm:**
+```bash
+echo "run -b com.microsoft:type=AI-alert,name=ProfilerControl triggerProfile" | \
+ java -jar jmxterm.jar -l
+```
+
+**Via JConsole:**
+
+Connect to the target JVM process, navigate to the MBeans tab, expand
+`com.microsoft` → `AI-alert` → `ProfilerControl`, and invoke the `triggerProfile` operation.
+
+Both manual triggering mechanisms respect the `globalCooldownSeconds` setting — if a profile was
+recently recorded, manual triggers will be suppressed until the cooldown expires.