From 1d23d18c0c9b4fc435106ed1f2acf5c724ccade8 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:39:32 +0100 Subject: [PATCH 1/7] Add targeted profiling support Add role- and instance-targeted profile collection, backward-compatible Service Profiler protocol negotiation, and settings moniker correlation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + CHANGELOG.md | 3 + .../alerting/alert/AlertBreach.java | 9 + .../config/AlertingConfiguration.java | 46 +++-- .../AlertingSubsystemConfiguration.java | 27 +++ .../TargetedCollectionPlanConfiguration.java | 125 +++++++++++++ .../config/TargetedInstanceConfiguration.java | 21 +++ .../alerting/AlertingSubsystem.java | 113 +++++++++--- .../alerting/ExecutedMonikerTracker.java | 65 +++++++ .../alerting/AlertingSubsystemTest.java | 111 ++++++++++++ .../alerting/ExecutedMonikerTrackerTest.java | 43 +++++ .../PerformanceMonitoringService.java | 4 +- .../profiler/ProfilingInitializer.java | 4 +- .../config/ProfilerConfiguration.java | 16 ++ .../config/TargetedCollectionPlan.java | 110 ++++++++++++ .../profiler/config/TargetedInstance.java | 65 +++++++ .../service/ServiceProfilerClient.java | 39 +++- .../profiler/triggers/AlertConfigParser.java | 91 ++++++++-- .../triggers/AlertingSubsystemInit.java | 10 +- .../profiler/upload/ServiceProfilerIndex.java | 7 + .../profiler/upload/UploadService.java | 84 +++++++-- .../profiler/ProfilingInitializerTest.java | 25 +++ .../config/ProfilerConfigurationTest.java | 44 +++++ .../service/ServiceProfilerClientTest.java | 81 +++++++++ .../triggers/AlertConfigParserTest.java | 166 ++++++++++++++++++ .../profiler/upload/UploadServiceTest.java | 10 +- ...adServiceResponseDoesNotProvideReturn.json | 2 +- .../ConfigServiceTest.pullSettings.json | 2 +- .../smoketest/JavaProfileConfigTest.java | 22 +++ .../MockedProfilerSettingsServlet.java | 38 ++++ .../fakeingestion/ProfilerState.java | 4 +- 31 files changed, 1309 insertions(+), 80 deletions(-) create mode 100644 agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingSubsystemConfiguration.java create mode 100644 agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedCollectionPlanConfiguration.java create mode 100644 agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedInstanceConfiguration.java create mode 100644 agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTracker.java create mode 100644 agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTrackerTest.java create mode 100644 agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedCollectionPlan.java create mode 100644 agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedInstance.java create mode 100644 agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClientTest.java diff --git a/.gitignore b/.gitignore index 37a233e35c0..039537c035e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ build/ # VSCode /.vscode/ bin/ + +/docs/adr/targettedProfiling/targettedProfiling_goal.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5325ecc93b8..14fb2ae47ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Enhancements +* Add Java agent support for ServiceProfiler targeted collection plans by cloud role or + role-qualified instance + * Add continuous profiling (`enableContinuousProfiling`, `continuousProfilingMaxAgeSeconds`) which keeps a single JFR recording running in a circular buffer so profile requests dump the most recent window of data immediately diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java index f6e07b844b1..84199fc845d 100644 --- a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java @@ -10,6 +10,7 @@ import com.microsoft.applicationinsights.alerting.config.AlertMetricType; import java.io.IOException; import java.util.UUID; +import javax.annotation.Nullable; /** Represents a breach of an alert threshold. */ @AutoValue @@ -68,6 +69,9 @@ public AlertBreach setProfileId(String profileId) { return this; } + @Nullable + public abstract String getSettingsMoniker(); + public abstract Builder toBuilder(); public static AlertBreach.Builder builder() { @@ -88,6 +92,9 @@ public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { jsonWriter.writeDoubleField("cpuMetric", cpuMetric); jsonWriter.writeDoubleField("memoryUsage", memoryUsage); jsonWriter.writeStringField("profileId", profileId); + if (getSettingsMoniker() != null) { + jsonWriter.writeStringField("settingsMoniker", getSettingsMoniker()); + } jsonWriter.writeEndObject(); return jsonWriter; } @@ -113,6 +120,8 @@ public abstract static class Builder implements JsonSerializable { public abstract Builder setProfileId(String profileId); + public abstract Builder setSettingsMoniker(@Nullable String settingsMoniker); + public abstract AlertBreach build(); @Override diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingConfiguration.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingConfiguration.java index 490c9cf5d58..3b96e6e566b 100644 --- a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingConfiguration.java +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingConfiguration.java @@ -6,6 +6,7 @@ import com.google.auto.value.AutoValue; import java.time.Instant; import java.util.List; +import javax.annotation.Nullable; /** Contains the overall configuration of the entire alerting subsystem. */ @AutoValue @@ -17,24 +18,46 @@ public static AlertingConfiguration create( DefaultConfiguration defaultConfiguration, CollectionPlanConfiguration collectionPlanConfiguration, List requestAlertConfiguration) { + return create( + cpuAlert, + memoryAlert, + defaultConfiguration, + collectionPlanConfiguration, + requestAlertConfiguration, + null); + } + + public static AlertingConfiguration create( + AlertConfiguration cpuAlert, + AlertConfiguration memoryAlert, + DefaultConfiguration defaultConfiguration, + CollectionPlanConfiguration collectionPlanConfiguration, + List requestAlertConfiguration, + @Nullable TargetedCollectionPlanConfiguration targetedCollectionPlanConfiguration) { return new AutoValue_AlertingConfiguration( cpuAlert, memoryAlert, defaultConfiguration, collectionPlanConfiguration, - requestAlertConfiguration); + requestAlertConfiguration, + targetedCollectionPlanConfiguration); } - public boolean hasAnEnabledTrigger() { + public boolean hasAnEnabledTrigger( + @Nullable String roleName, @Nullable String roleInstance, Instant now) { + CollectionPlanConfiguration collectionPlan = getCollectionPlanConfiguration(); boolean manualProfileEnabled = - getCollectionPlanConfiguration().isSingle() - && getCollectionPlanConfiguration().getMode() - == CollectionPlanConfiguration.EngineMode.immediate - && Instant.now().isBefore(getCollectionPlanConfiguration().getExpiration()); - - return getCpuAlert().isEnabled() || manualProfileEnabled || getMemoryAlert().isEnabled(); - // Sampling not enabled yet - // getDefaultConfiguration().getSamplingEnabled(); + collectionPlan.isSingle() + && collectionPlan.getMode() == CollectionPlanConfiguration.EngineMode.immediate + && now.isBefore(collectionPlan.getExpiration()); + + TargetedCollectionPlanConfiguration targetedPlan = getTargetedCollectionPlanConfiguration(); + boolean onDemandProfileEnabled = + targetedPlan == null + ? manualProfileEnabled + : targetedPlan.isActionable(roleName, roleInstance, now); + + return getCpuAlert().isEnabled() || onDemandProfileEnabled || getMemoryAlert().isEnabled(); } public boolean hasRequestAlertConfiguration() { @@ -55,4 +78,7 @@ public boolean hasRequestAlertConfiguration() { // Alert configuration for SPAN telemetry public abstract List getRequestAlertConfiguration(); + + @Nullable + public abstract TargetedCollectionPlanConfiguration getTargetedCollectionPlanConfiguration(); } diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingSubsystemConfiguration.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingSubsystemConfiguration.java new file mode 100644 index 00000000000..f7a81d7180f --- /dev/null +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/AlertingSubsystemConfiguration.java @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.alerting.config; + +import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + +@AutoValue +public abstract class AlertingSubsystemConfiguration { + + public static AlertingSubsystemConfiguration create( + @Nullable String roleName, + @Nullable String roleInstance, + AlertingProfileFileTriggerConfiguration profileFileTriggerConfiguration) { + return new AutoValue_AlertingSubsystemConfiguration( + roleName, roleInstance, profileFileTriggerConfiguration); + } + + @Nullable + public abstract String getRoleName(); + + @Nullable + public abstract String getRoleInstance(); + + public abstract AlertingProfileFileTriggerConfiguration getProfileFileTriggerConfiguration(); +} diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedCollectionPlanConfiguration.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedCollectionPlanConfiguration.java new file mode 100644 index 00000000000..4438cc5258d --- /dev/null +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedCollectionPlanConfiguration.java @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.alerting.config; + +import com.google.auto.value.AutoValue; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +@AutoValue +public abstract class TargetedCollectionPlanConfiguration { + + public static TargetedCollectionPlanConfiguration create( + @Nullable List roles, + @Nullable List instances, + int immediateProfilingDurationSeconds, + @Nullable Instant expiration, + @Nullable String settingsMoniker) { + return new AutoValue_TargetedCollectionPlanConfiguration( + immutableCopy(roles), + immutableCopy(instances), + immediateProfilingDurationSeconds, + expiration, + settingsMoniker); + } + + @Nullable + public abstract List getRoles(); + + @Nullable + public abstract List getInstances(); + + public abstract int getImmediateProfilingDurationSeconds(); + + @Nullable + public abstract Instant getExpiration(); + + @Nullable + public abstract String getSettingsMoniker(); + + public boolean isValid() { + List roles = getRoles(); + List instances = getInstances(); + if ((roles == null) == (instances == null) + || getImmediateProfilingDurationSeconds() < 1 + || getImmediateProfilingDurationSeconds() > 360 + || getExpiration() == null + || isBlank(getSettingsMoniker())) { + return false; + } + + if (roles != null) { + if (roles.isEmpty()) { + return false; + } + for (String role : roles) { + if (isBlank(role)) { + return false; + } + } + return true; + } + + if (instances.isEmpty()) { + return false; + } + for (TargetedInstanceConfiguration instance : instances) { + if (instance == null || isBlank(instance.getRole()) || isBlank(instance.getName())) { + return false; + } + } + return true; + } + + public boolean isSelected(@Nullable String roleName, @Nullable String roleInstance) { + if (!isValid() || isBlank(roleName)) { + return false; + } + + List roles = getRoles(); + if (roles != null) { + for (String role : roles) { + if (equalsNormalized(role, roleName)) { + return true; + } + } + return false; + } + + List instances = getInstances(); + if (isBlank(roleInstance) || instances == null) { + return false; + } + for (TargetedInstanceConfiguration instance : instances) { + if (instance != null + && equalsNormalized(instance.getRole(), roleName) + && equalsNormalized(instance.getName(), roleInstance)) { + return true; + } + } + return false; + } + + public boolean isActionable( + @Nullable String roleName, @Nullable String roleInstance, Instant now) { + Instant expiration = getExpiration(); + return expiration != null && now.isBefore(expiration) && isSelected(roleName, roleInstance); + } + + @Nullable + private static List immutableCopy(@Nullable List values) { + return values == null ? null : Collections.unmodifiableList(new ArrayList<>(values)); + } + + private static boolean equalsNormalized(@Nullable String left, @Nullable String right) { + return left != null && right != null && left.trim().equalsIgnoreCase(right.trim()); + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedInstanceConfiguration.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedInstanceConfiguration.java new file mode 100644 index 00000000000..27e4de6f8c6 --- /dev/null +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/config/TargetedInstanceConfiguration.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.alerting.config; + +import com.google.auto.value.AutoValue; +import javax.annotation.Nullable; + +@AutoValue +public abstract class TargetedInstanceConfiguration { + + public static TargetedInstanceConfiguration create(@Nullable String role, @Nullable String name) { + return new AutoValue_TargetedInstanceConfiguration(role, name); + } + + @Nullable + public abstract String getRole(); + + @Nullable + public abstract String getName(); +} 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 f8508defb72..ef11cd622d8 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 @@ -15,12 +15,11 @@ import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration; import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode; import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedCollectionPlanConfiguration; import java.io.File; import java.time.Instant; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.UUID; import java.util.function.Consumer; import javax.annotation.Nullable; @@ -38,11 +37,12 @@ public class AlertingSubsystem { // Downstream observer of alerts produced by the alerting system private final Consumer alertHandler; - // List of manual triggers that have already been processed - private final Set manualTriggersExecuted = new HashSet<>(); + private final ExecutedMonikerTracker executedMonikers; private final AlertPipelines alertPipelines; private final TimeSource timeSource; + @Nullable private final String roleName; + @Nullable private final String roleInstance; // Current configuration of the alerting subsystem private AlertingConfiguration alertConfig; @@ -57,11 +57,32 @@ protected AlertingSubsystem( TimeSource timeSource, boolean enableRequestTriggerUpdates, AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) { + this( + alertHandler, + timeSource, + enableRequestTriggerUpdates, + alertingProfileFileTriggerConfiguration, + null, + null, + new ExecutedMonikerTracker()); + } + + AlertingSubsystem( + Consumer alertHandler, + TimeSource timeSource, + boolean enableRequestTriggerUpdates, + AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration, + @Nullable String roleName, + @Nullable String roleInstance, + ExecutedMonikerTracker executedMonikers) { this.alertHandler = alertHandler; this.alertPipelines = new AlertPipelines(alertHandler); this.timeSource = timeSource; this.enableRequestTriggerUpdates = enableRequestTriggerUpdates; this.alertingProfileFileTriggerConfiguration = alertingProfileFileTriggerConfiguration; + this.roleName = roleName; + this.roleInstance = roleInstance; + this.executedMonikers = executedMonikers; } /** @@ -76,10 +97,25 @@ public static AlertingSubsystem create( Consumer alertHandler, TimeSource timeSource, AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) { + return create(alertHandler, timeSource, null, null, alertingProfileFileTriggerConfiguration); + } + + public static AlertingSubsystem create( + Consumer alertHandler, + TimeSource timeSource, + @Nullable String roleName, + @Nullable String roleInstance, + AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) { AlertingSubsystem alertingSubsystem = new AlertingSubsystem( - alertHandler, timeSource, true, alertingProfileFileTriggerConfiguration); + alertHandler, + timeSource, + true, + alertingProfileFileTriggerConfiguration, + roleName, + roleInstance, + new ExecutedMonikerTracker()); // init with disabled config alertingSubsystem.initialize( @@ -166,7 +202,11 @@ private void updateRequestPipelineConfig( * both the server-side collection plan and the local file-based trigger. */ private void evaluateManualTrigger(AlertingConfiguration alertConfig) { - evaluateCollectionPlanTrigger(alertConfig); + if (alertConfig.getTargetedCollectionPlanConfiguration() == null) { + evaluateCollectionPlanTrigger(alertConfig); + } else { + evaluateTargetedCollectionPlanTrigger(alertConfig); + } evaluateFileTrigger(alertConfig); } @@ -178,27 +218,52 @@ private void evaluateCollectionPlanTrigger(AlertingConfiguration alertConfig) { config.isSingle() && config.getMode() == EngineMode.immediate && timeSource.getNow().isBefore(config.getExpiration()) - && !manualTriggersExecuted.contains(config.getSettingsMoniker()); + && executedMonikers.tryMarkExecuted(config.getSettingsMoniker(), timeSource.getNow()); if (shouldTrigger) { - manualTriggersExecuted.add(config.getSettingsMoniker()); - - AlertBreach alertBreach = - AlertBreach.builder() - .setType(AlertMetricType.MANUAL) - .setAlertValue(0.0) - .setAlertConfiguration( - AlertConfiguration.builder() - .setType(AlertMetricType.MANUAL) - .setEnabled(true) - .setProfileDurationSeconds(config.getImmediateProfilingDurationSeconds()) - .build()) - .setProfileId(UUID.randomUUID().toString()) - .setCpuMetric(0) - .setMemoryUsage(0) - .build(); - alertHandler.accept(alertBreach); + dispatchManualAlert( + config.getImmediateProfilingDurationSeconds(), config.getSettingsMoniker()); + } + } + + private void evaluateTargetedCollectionPlanTrigger(AlertingConfiguration alertConfig) { + TargetedCollectionPlanConfiguration config = + alertConfig.getTargetedCollectionPlanConfiguration(); + if (config == null) { + return; + } + if (!config.isValid()) { + logger.warn("Ignoring invalid targeted profiler collection plan"); + return; + } + if (!config.isActionable(roleName, roleInstance, timeSource.getNow())) { + return; } + + String settingsMoniker = config.getSettingsMoniker(); + if (settingsMoniker != null + && executedMonikers.tryMarkExecuted(settingsMoniker, timeSource.getNow())) { + dispatchManualAlert(config.getImmediateProfilingDurationSeconds(), settingsMoniker); + } + } + + private void dispatchManualAlert(int durationSeconds, String settingsMoniker) { + 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) + .setSettingsMoniker(settingsMoniker) + .build(); + alertHandler.accept(alertBreach); } /** diff --git a/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTracker.java b/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTracker.java new file mode 100644 index 00000000000..311160a2dce --- /dev/null +++ b/agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTracker.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.alerting; + +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +final class ExecutedMonikerTracker { + + static final Duration DEFAULT_RETENTION = Duration.ofMinutes(10); + static final int DEFAULT_CAPACITY = 1024; + + private final Duration retention; + private final int capacity; + private final LinkedHashMap executed = new LinkedHashMap<>(); + + ExecutedMonikerTracker() { + this(DEFAULT_RETENTION, DEFAULT_CAPACITY); + } + + ExecutedMonikerTracker(Duration retention, int capacity) { + if (retention.isNegative() || retention.isZero()) { + throw new IllegalArgumentException("retention must be positive"); + } + if (capacity < 1) { + throw new IllegalArgumentException("capacity must be positive"); + } + this.retention = retention; + this.capacity = capacity; + } + + synchronized boolean tryMarkExecuted(String moniker, Instant now) { + if (moniker == null || moniker.trim().isEmpty()) { + return false; + } + + removeExpired(now); + String normalizedMoniker = moniker.trim(); + if (executed.containsKey(normalizedMoniker)) { + return false; + } + + while (executed.size() >= capacity) { + Iterator iterator = executed.keySet().iterator(); + iterator.next(); + iterator.remove(); + } + executed.put(normalizedMoniker, now); + return true; + } + + private void removeExpired(Instant now) { + Instant cutoff = now.minus(retention); + Iterator> iterator = executed.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getValue().isBefore(cutoff)) { + iterator.remove(); + } + } + } +} 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 8f53a417731..5e8f08fca70 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 @@ -13,9 +13,12 @@ import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration; import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode; import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedCollectionPlanConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedInstanceConfiguration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -77,6 +80,7 @@ void alertTriggerIsCalled() { assertThat(called.get().getType()).isEqualTo(AlertMetricType.CPU); assertThat(called.get().getAlertValue()).isEqualTo(90.0); + assertThat(called.get().getSettingsMoniker()).isNull(); } @Test @@ -120,6 +124,7 @@ void manualAlertWorks() { new ArrayList<>())); assertThat(called.get().getType()).isEqualTo(AlertMetricType.MANUAL); + assertThat(called.get().getSettingsMoniker()).isEqualTo("a-settings-moniker"); } @Test @@ -165,4 +170,110 @@ void manualAlertDoesNotTriggerAfterExpired() { assertThat(called.get()).isNull(); } + + @Test + void targetedAlertTriggersOnlyForMatchingIdentity() { + AtomicReference matchingBreach = new AtomicReference<>(); + TestTimeSource timeSource = new TestTimeSource(); + AlertingConfiguration config = targetedAlertingConfig(false); + + AlertingSubsystem matching = + AlertingSubsystem.create( + matchingBreach::set, + timeSource, + "frontend", + "instance-1", + AlertingProfileFileTriggerConfiguration.createDefault()); + matching.updateConfiguration(config); + + AtomicReference unmatchedBreach = new AtomicReference<>(); + AlertingSubsystem unmatched = + AlertingSubsystem.create( + unmatchedBreach::set, + timeSource, + "backend", + "instance-1", + AlertingProfileFileTriggerConfiguration.createDefault()); + unmatched.updateConfiguration(config); + + assertThat(matchingBreach.get()).isNotNull(); + assertThat(matchingBreach.get().getType()).isEqualTo(AlertMetricType.MANUAL); + assertThat(matchingBreach.get().getSettingsMoniker()).isEqualTo("Portal_test"); + assertThat(unmatchedBreach.get()).isNull(); + } + + @Test + void targetedSelectionNormalizesRoleAndInstance() { + TargetedCollectionPlanConfiguration instancePlan = + TargetedCollectionPlanConfiguration.create( + null, + Collections.singletonList( + TargetedInstanceConfiguration.create(" frontend ", " instance-1 ")), + 120, + Instant.ofEpochSecond(60), + "Portal_test"); + + assertThat(instancePlan.isSelected("FRONTEND", "INSTANCE-1")).isTrue(); + assertThat(instancePlan.isSelected("frontend", "instance-2")).isFalse(); + assertThat(instancePlan.isSelected(null, "instance-1")).isFalse(); + } + + @Test + void targetedPlanIsActionableOnlyBeforeExpiration() { + TargetedCollectionPlanConfiguration rolePlan = + TargetedCollectionPlanConfiguration.create( + Collections.singletonList("frontend"), + null, + 120, + Instant.ofEpochSecond(60), + "Portal_test"); + + assertThat(rolePlan.isActionable("frontend", "instance-1", Instant.ofEpochSecond(59))).isTrue(); + assertThat(rolePlan.isActionable("frontend", "instance-1", Instant.ofEpochSecond(60))) + .isFalse(); + } + + @Test + void targetedPlanTakesPrecedenceOverLegacyPlan() { + AtomicReference breach = new AtomicReference<>(); + TestTimeSource timeSource = new TestTimeSource(); + AlertingSubsystem subsystem = + AlertingSubsystem.create( + breach::set, + timeSource, + "frontend", + "instance-1", + AlertingProfileFileTriggerConfiguration.createDefault()); + + subsystem.updateConfiguration(targetedAlertingConfig(true)); + + assertThat(breach.get()).isNotNull(); + assertThat(breach.get().getAlertConfiguration().getProfileDurationSeconds()).isEqualTo(120); + } + + private static AlertingConfiguration targetedAlertingConfig(boolean legacyEnabled) { + CollectionPlanConfiguration legacyPlan = + CollectionPlanConfiguration.builder() + .setSingle(legacyEnabled) + .setMode(EngineMode.immediate) + .setExpiration(Instant.ofEpochSecond(60)) + .setImmediateProfilingDurationSeconds(30) + .setSettingsMoniker("legacy") + .build(); + TargetedCollectionPlanConfiguration targetedPlan = + TargetedCollectionPlanConfiguration.create( + null, + Collections.singletonList( + TargetedInstanceConfiguration.create("frontend", "instance-1")), + 120, + Instant.ofEpochSecond(60), + "Portal_test"); + return AlertingConfiguration.create( + AlertConfiguration.builder().setType(AlertMetricType.CPU).build(), + AlertConfiguration.builder().setType(AlertMetricType.MEMORY).build(), + DefaultConfiguration.builder().build(), + legacyPlan, + new ArrayList<>(), + targetedPlan); + } } diff --git a/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTrackerTest.java b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTrackerTest.java new file mode 100644 index 00000000000..b0f04461196 --- /dev/null +++ b/agent/agent-profiler/agent-alerting/src/test/java/com/microsoft/applicationinsights/alerting/ExecutedMonikerTrackerTest.java @@ -0,0 +1,43 @@ +// 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 java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ExecutedMonikerTrackerTest { + + @Test + void rejectsDuplicateWithinRetentionWindow() { + ExecutedMonikerTracker tracker = new ExecutedMonikerTracker(Duration.ofMinutes(10), 10); + Instant now = Instant.parse("2026-07-24T13:58:12Z"); + + assertThat(tracker.tryMarkExecuted("Portal_test", now)).isTrue(); + assertThat(tracker.tryMarkExecuted("Portal_test", now.plusSeconds(60))).isFalse(); + assertThat(tracker.tryMarkExecuted("Portal_test", now.plusSeconds(601))).isTrue(); + } + + @Test + void evictsOldestEntryAtCapacity() { + ExecutedMonikerTracker tracker = new ExecutedMonikerTracker(Duration.ofMinutes(10), 2); + Instant now = Instant.parse("2026-07-24T13:58:12Z"); + + assertThat(tracker.tryMarkExecuted("one", now)).isTrue(); + assertThat(tracker.tryMarkExecuted("two", now)).isTrue(); + assertThat(tracker.tryMarkExecuted("three", now)).isTrue(); + assertThat(tracker.tryMarkExecuted("one", now.plusSeconds(1))).isTrue(); + } + + @Test + void rejectsBlankMoniker() { + ExecutedMonikerTracker tracker = new ExecutedMonikerTracker(Duration.ofMinutes(10), 10); + Instant now = Instant.parse("2026-07-24T13:58:12Z"); + + assertThat(tracker.tryMarkExecuted(null, now)).isFalse(); + assertThat(tracker.tryMarkExecuted(" ", now)).isFalse(); + } +} 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 bf8e12fc240..ea2b7912123 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 @@ -16,6 +16,7 @@ import com.microsoft.applicationinsights.alerting.AlertingSubsystem; import com.microsoft.applicationinsights.alerting.config.AlertingConfiguration; import com.microsoft.applicationinsights.alerting.config.AlertingProfileFileTriggerConfiguration; +import com.microsoft.applicationinsights.alerting.config.AlertingSubsystemConfiguration; import com.microsoft.applicationinsights.diagnostics.DiagnosticEngine; import com.microsoft.applicationinsights.diagnostics.DiagnosticEngineFactory; import com.microsoft.applicationinsights.diagnostics.appinsights.CodeOptimizerApplicationInsightFactoryJfr; @@ -120,7 +121,8 @@ synchronized void enableProfiler( telemetryClient, diagnosticEngine, alertServiceExecutorService, - alertingProfileFileTriggerConfiguration); + AlertingSubsystemConfiguration.create( + roleName, machineName, alertingProfileFileTriggerConfiguration)); uploadService = new UploadService( 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 4420a6c4a2c..739122ba8d6 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 @@ -23,6 +23,7 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; +import java.time.Instant; import java.util.Arrays; import java.util.HashSet; import java.util.concurrent.Executors; @@ -189,7 +190,8 @@ synchronized void applyConfiguration(ProfilerConfiguration config) { boolean manualProfilingConfigured = configuration.manualTrigger.enabled || configuration.enableProfilerControlMBean; - if (alertingConfig.hasAnEnabledTrigger() || manualProfilingConfigured) { + if (alertingConfig.hasAnEnabledTrigger(roleName, machineName, Instant.now()) + || manualProfilingConfigured) { if (!currentlyEnabled.getAndSet(true)) { enableProfiler(); } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfiguration.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfiguration.java index 2c5951c85ab..7fcf91c2db9 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfiguration.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfiguration.java @@ -42,6 +42,7 @@ public class ProfilerConfiguration implements JsonSerializable requestTriggerConfiguration; + @Nullable private TargetedCollectionPlan targetedCollectionPlan; public boolean hasBeenConfigured() { return getLastModified().compareTo(DEFAULT_DATE) != 0; @@ -134,6 +135,17 @@ public ProfilerConfiguration setRequestTriggerConfiguration( return this; } + @Nullable + public TargetedCollectionPlan getTargetedCollectionPlan() { + return targetedCollectionPlan; + } + + public ProfilerConfiguration setTargetedCollectionPlan( + @Nullable TargetedCollectionPlan targetedCollectionPlan) { + this.targetedCollectionPlan = targetedCollectionPlan; + return this; + } + @Override public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { jsonWriter.writeStartObject(); @@ -151,6 +163,7 @@ public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { trigger.toJson(jsonWriter); } jsonWriter.writeEndArray(); + jsonWriter.writeJsonField("targetedCollectionPlan", targetedCollectionPlan); jsonWriter.writeEndObject(); return jsonWriter; } @@ -194,6 +207,9 @@ public static ProfilerConfiguration fromJson(JsonReader jsonReader) throws IOExc } else if ("requestTriggerConfiguration".equals(fieldName)) { deserializedProfilerConfiguration.setRequestTriggerConfiguration( reader.readArray(AlertingConfig.RequestTrigger::fromJson)); + } else if ("targetedCollectionPlan".equals(fieldName)) { + deserializedProfilerConfiguration.setTargetedCollectionPlan( + TargetedCollectionPlan.fromJson(reader)); } else { reader.skipChildren(); } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedCollectionPlan.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedCollectionPlan.java new file mode 100644 index 00000000000..f6c070447d3 --- /dev/null +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedCollectionPlan.java @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.agent.internal.profiler.config; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; +import java.io.IOException; +import java.util.List; +import javax.annotation.Nullable; + +public class TargetedCollectionPlan implements JsonSerializable { + + @Nullable private List roles; + @Nullable private List instances; + private int immediateProfilingDuration; + @Nullable private String expiration; + @Nullable private String settingsMoniker; + + @Nullable + public List getRoles() { + return roles; + } + + public TargetedCollectionPlan setRoles(@Nullable List roles) { + this.roles = roles; + return this; + } + + @Nullable + public List getInstances() { + return instances; + } + + public TargetedCollectionPlan setInstances(@Nullable List instances) { + this.instances = instances; + return this; + } + + public int getImmediateProfilingDuration() { + return immediateProfilingDuration; + } + + public TargetedCollectionPlan setImmediateProfilingDuration(int immediateProfilingDuration) { + this.immediateProfilingDuration = immediateProfilingDuration; + return this; + } + + @Nullable + public String getExpiration() { + return expiration; + } + + public TargetedCollectionPlan setExpiration(@Nullable String expiration) { + this.expiration = expiration; + return this; + } + + @Nullable + public String getSettingsMoniker() { + return settingsMoniker; + } + + public TargetedCollectionPlan setSettingsMoniker(@Nullable String settingsMoniker) { + this.settingsMoniker = settingsMoniker; + return this; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + jsonWriter.writeStartObject(); + if (roles != null) { + jsonWriter.writeArrayField("roles", roles, JsonWriter::writeString); + } + if (instances != null) { + jsonWriter.writeArrayField("instances", instances, JsonWriter::writeJson); + } + jsonWriter.writeIntField("immediateProfilingDuration", immediateProfilingDuration); + jsonWriter.writeStringField("expiration", expiration); + jsonWriter.writeStringField("settingsMoniker", settingsMoniker); + return jsonWriter.writeEndObject(); + } + + public static TargetedCollectionPlan fromJson(JsonReader jsonReader) throws IOException { + return jsonReader.readObject( + reader -> { + TargetedCollectionPlan plan = new TargetedCollectionPlan(); + while (reader.nextToken() != JsonToken.END_OBJECT) { + reader.nextToken(); + String fieldName = reader.getFieldName(); + if ("roles".equals(fieldName)) { + plan.setRoles(reader.readArray(JsonReader::getString)); + } else if ("instances".equals(fieldName)) { + plan.setInstances(reader.readArray(TargetedInstance::fromJson)); + } else if ("immediateProfilingDuration".equals(fieldName)) { + plan.setImmediateProfilingDuration(reader.getInt()); + } else if ("expiration".equals(fieldName)) { + plan.setExpiration(reader.getString()); + } else if ("settingsMoniker".equals(fieldName)) { + plan.setSettingsMoniker(reader.getString()); + } else { + reader.skipChildren(); + } + } + return plan; + }); + } +} diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedInstance.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedInstance.java new file mode 100644 index 00000000000..3e3f50e603e --- /dev/null +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/TargetedInstance.java @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.agent.internal.profiler.config; + +import com.azure.json.JsonReader; +import com.azure.json.JsonSerializable; +import com.azure.json.JsonToken; +import com.azure.json.JsonWriter; +import java.io.IOException; +import javax.annotation.Nullable; + +public class TargetedInstance implements JsonSerializable { + + @Nullable private String role; + @Nullable private String name; + + @Nullable + public String getRole() { + return role; + } + + public TargetedInstance setRole(@Nullable String role) { + this.role = role; + return this; + } + + @Nullable + public String getName() { + return name; + } + + public TargetedInstance setName(@Nullable String name) { + this.name = name; + return this; + } + + @Override + public JsonWriter toJson(JsonWriter jsonWriter) throws IOException { + return jsonWriter + .writeStartObject() + .writeStringField("role", role) + .writeStringField("name", name) + .writeEndObject(); + } + + public static TargetedInstance fromJson(JsonReader jsonReader) throws IOException { + return jsonReader.readObject( + reader -> { + TargetedInstance instance = new TargetedInstance(); + while (reader.nextToken() != JsonToken.END_OBJECT) { + reader.nextToken(); + String fieldName = reader.getFieldName(); + if ("role".equals(fieldName)) { + instance.setRole(reader.getString()); + } else if ("name".equals(fieldName)) { + instance.setName(reader.getString()); + } else { + reader.skipChildren(); + } + } + return instance; + }); + } +} diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClient.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClient.java index 829a165eaf4..2aa838afa22 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClient.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClient.java @@ -35,13 +35,16 @@ public class ServiceProfilerClient { private static final String SETTINGS_PATH = PROFILER_API_PREFIX + "/settings"; public static final String OLD_TIMESTAMP_PARAMETER = "oldTimestamp"; public static final String FEATURE_VERSION_PARAMETER = "featureVersion"; - public static final String FEATURE_VERSION = "1.0.0"; + public static final String FEATURE_VERSION = "2.0.0"; + private static final String LEGACY_FEATURE_VERSION = "1.0.0"; + private static final String EMPTY_GUID = "00000000-0000-0000-0000-000000000000"; public static final String API_FEATURE_VERSION = "2020-10-14-preview"; private final URL hostUrl; private final String instrumentationKey; private final HttpPipeline httpPipeline; @Nullable private final String userAgent; + private volatile String settingsFeatureVersion = FEATURE_VERSION; public ServiceProfilerClient( URL hostUrl, @@ -144,15 +147,39 @@ private static Mono reportUploadFinish(HttpResponse response) { /** Obtain current settings that have been configured within the UI. */ public Mono getSettings(Date oldTimeStamp) { + String featureVersion = settingsFeatureVersion; + return getSettings(oldTimeStamp, featureVersion) + .flatMap( + config -> { + if (FEATURE_VERSION.equals(featureVersion) + && isUnsupportedFeatureVersionResponse(config)) { + logger.info( + "Service Profiler settings protocol {} is not supported; falling back to {}", + FEATURE_VERSION, + LEGACY_FEATURE_VERSION); + settingsFeatureVersion = LEGACY_FEATURE_VERSION; + return getSettings(oldTimeStamp, LEGACY_FEATURE_VERSION); + } + return Mono.just(config); + }); + } - URL requestUrl = getSettingsPath(oldTimeStamp); - + private Mono getSettings(Date oldTimeStamp, String featureVersion) { + URL requestUrl = getSettingsPath(oldTimeStamp, featureVersion); HttpRequest request = new HttpRequest(HttpMethod.GET, requestUrl); - return httpPipeline.send(request).flatMap(response -> handle(response, requestUrl)); } + private static boolean isUnsupportedFeatureVersionResponse(ProfilerConfiguration config) { + String id = config.id(); + return !config.isEnabled() && (id == null || id.isEmpty() || EMPTY_GUID.equals(id)); + } + private static Mono handle(HttpResponse response, URL requestUrl) { + if (response.getStatusCode() == 304) { + response.close(); + return Mono.empty(); + } if (response.getStatusCode() >= 300) { // need to consume the body or close the response, otherwise get netty ByteBuf leak warnings: // io.netty.util.ResourceLeakDetector - LEAK: ByteBuf.release() was not called before @@ -175,7 +202,7 @@ private static Mono handle(HttpResponse response, URL req } // api/profileragent/v4/settings?ikey=xyz&featureVersion=1.0.0&oldTimestamp=123 - private URL getSettingsPath(Date oldTimeStamp) { + private URL getSettingsPath(Date oldTimeStamp, String featureVersion) { String path = SETTINGS_PATH @@ -190,7 +217,7 @@ private URL getSettingsPath(Date oldTimeStamp) { + "&" + FEATURE_VERSION_PARAMETER + "=" - + FEATURE_VERSION; + + featureVersion; try { return new URL(hostUrl, path); diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParser.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParser.java index 55de44fd381..8bb61721c6c 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParser.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParser.java @@ -4,6 +4,8 @@ package com.microsoft.applicationinsights.agent.internal.profiler.triggers; import com.microsoft.applicationinsights.agent.internal.profiler.config.ProfilerConfiguration; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedCollectionPlan; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedInstance; import com.microsoft.applicationinsights.alerting.aiconfig.AlertingConfig; import com.microsoft.applicationinsights.alerting.config.AlertConfiguration; import com.microsoft.applicationinsights.alerting.config.AlertMetricType; @@ -11,19 +13,27 @@ import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration; import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode; import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedCollectionPlanConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedInstanceConfiguration; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Parses the configuration from the service profiler endpoint. */ public class AlertConfigParser { + private static final Logger logger = LoggerFactory.getLogger(AlertConfigParser.class); + static AlertingConfiguration parse( String cpuConfig, String memoryConfig, @@ -61,13 +71,7 @@ private static List buildRequestTriggerConfiguration( // --settings-moniker Portal_b5bd7880-7406-4058-a6f8-3ea0102706b1 private static CollectionPlanConfiguration parseCollectionPlan(@Nullable String collectionPlan) { if (collectionPlan == null || collectionPlan.isEmpty()) { - return CollectionPlanConfiguration.builder() - .setSingle(false) - .setMode(EngineMode.immediate) - .setExpiration(Instant.ofEpochMilli(0)) - .setImmediateProfilingDurationSeconds(0) - .setSettingsMoniker("") - .build(); + return disabledCollectionPlan(); } String[] tokens = collectionPlan.split(" "); @@ -90,7 +94,22 @@ private static CollectionPlanConfiguration parseCollectionPlan(@Nullable String "settings-moniker", new ParseConfigValue<>(true, (config, arg) -> config.setSettingsMoniker(arg))); - return parseConfig(CollectionPlanConfiguration.builder(), tokens, parsers).build(); + try { + return parseConfig(CollectionPlanConfiguration.builder(), tokens, parsers).build(); + } catch (NumberFormatException | IllegalStateException e) { + logger.warn("Ignoring invalid profiler collection plan", e); + return disabledCollectionPlan(); + } + } + + private static CollectionPlanConfiguration disabledCollectionPlan() { + return CollectionPlanConfiguration.builder() + .setSingle(false) + .setMode(EngineMode.immediate) + .setExpiration(Instant.ofEpochMilli(0)) + .setImmediateProfilingDurationSeconds(0) + .setSettingsMoniker("") + .build(); } static DefaultConfiguration parseDefaultConfiguration(@Nullable String defaultConfig) { @@ -227,13 +246,57 @@ private static T parseConfig( public static AlertingConfiguration toAlertingConfig( ProfilerConfiguration profilerConfiguration) { + String legacyPlan = profilerConfiguration.getCollectionPlan(); + TargetedCollectionPlan targetedPlan = profilerConfiguration.getTargetedCollectionPlan(); + + return AlertingConfiguration.create( + parseFromCpu(profilerConfiguration.getCpuTriggerConfiguration()), + parseFromMemory(profilerConfiguration.getMemoryTriggerConfiguration()), + parseDefaultConfiguration(profilerConfiguration.getDefaultConfiguration()), + parseCollectionPlan(legacyPlan), + buildRequestTriggerConfiguration(profilerConfiguration.getRequestTriggerConfiguration()), + parseTargetedCollectionPlan(targetedPlan)); + } + + @Nullable + private static TargetedCollectionPlanConfiguration parseTargetedCollectionPlan( + @Nullable TargetedCollectionPlan plan) { + if (plan == null) { + return null; + } + + List instances = null; + if (plan.getInstances() != null) { + instances = new ArrayList<>(); + for (TargetedInstance instance : plan.getInstances()) { + instances.add( + instance == null + ? null + : TargetedInstanceConfiguration.create(instance.getRole(), instance.getName())); + } + } + + Instant expiration = null; + if (!isBlank(plan.getExpiration())) { + try { + expiration = + OffsetDateTime.parse(plan.getExpiration(), DateTimeFormatter.ISO_OFFSET_DATE_TIME) + .toInstant(); + } catch (DateTimeParseException e) { + logger.warn("Targeted profiler collection plan has invalid expiration"); + } + } + + return TargetedCollectionPlanConfiguration.create( + plan.getRoles(), + instances, + plan.getImmediateProfilingDuration(), + expiration, + plan.getSettingsMoniker()); + } - return AlertConfigParser.parse( - profilerConfiguration.getCpuTriggerConfiguration(), - profilerConfiguration.getMemoryTriggerConfiguration(), - profilerConfiguration.getDefaultConfiguration(), - profilerConfiguration.getCollectionPlan(), - profilerConfiguration.getRequestTriggerConfiguration()); + private static boolean isBlank(@Nullable String value) { + return value == null || value.trim().isEmpty(); } // visible for testing 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 e843b02bca6..a18e26e765f 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 @@ -25,7 +25,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.alerting.config.AlertingSubsystemConfiguration; import com.microsoft.applicationinsights.diagnostics.DiagnosticEngine; import java.util.List; import java.util.Map; @@ -51,7 +51,7 @@ public static AlertingSubsystem create( TelemetryClient telemetryClient, DiagnosticEngine diagnosticEngine, ExecutorService executorService, - AlertingProfileFileTriggerConfiguration alertingProfileFileTriggerConfiguration) { + AlertingSubsystemConfiguration alertingSubsystemConfiguration) { // TODO (trask) delay creation of AlertingSubsystem until after Profiler is created and // initialized? @@ -66,7 +66,11 @@ public static AlertingSubsystem create( alertingSubsystem = AlertingSubsystem.create( - alertAction, TimeSource.DEFAULT, alertingProfileFileTriggerConfiguration); + alertAction, + TimeSource.DEFAULT, + alertingSubsystemConfiguration.getRoleName(), + alertingSubsystemConfiguration.getRoleInstance(), + alertingSubsystemConfiguration.getProfileFileTriggerConfiguration()); if (configuration.enableRequestTriggering) { if (!configuration.requestTriggerEndpoints.isEmpty()) { diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/ServiceProfilerIndex.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/ServiceProfilerIndex.java index 4b626385a5e..c9a5db78aeb 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/ServiceProfilerIndex.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/ServiceProfilerIndex.java @@ -43,6 +43,8 @@ public static class Builder { private static final String SERVICE_PROFILER_PROCESSID_PROPERTY_NAME = "ProcessId"; // visible for testing public static final String SERVICE_PROFILER_ETLFILESESSIONID_PROPERTY_NAME = "EtlFileSessionId"; + // visible for testing + public static final String SERVICE_PROFILER_SETTINGS_MONIKER_PROPERTY_NAME = "SettingsMoniker"; private static final String SERVICE_PROFILER_OPERATINGSYSTEM_PROPERTY_NAME = "OperatingSystem"; private static final String SERVICE_PROFILER_AVERAGECPUUSAGE_METRIC_NAME = "AverageCPUUsage"; private static final String SERVICE_PROFILER_AVERAGE_MEMORY_USAGE_METRIC_NAME = @@ -79,6 +81,11 @@ public Builder setTimeStamp(String timeStamp) { return this; } + public Builder setSettingsMoniker(String settingsMoniker) { + sampleEvent.put(SERVICE_PROFILER_SETTINGS_MONIKER_PROPERTY_NAME, settingsMoniker); + return this; + } + public Builder setMachineName(String machineName) { sampleEvent.put(SERVICE_PROFILER_MACHINENAME_PROPERTY_NAME, machineName); return this; diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadService.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadService.java index 35d4a6d3251..0d52d53d9f9 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadService.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadService.java @@ -100,7 +100,8 @@ public void upload( timestamp, file, alertBreach.getCpuMetric(), - alertBreach.getMemoryUsage()) + alertBreach.getMemoryUsage(), + alertBreach.getSettingsMoniker()) .subscribe(onUploadComplete(uploadListener), e -> logger.error("Failed to upload file", e)); } @@ -122,9 +123,29 @@ Mono uploadJfrFile( File file, double cpuUsage, double memoryUsage) { + return uploadJfrFile(profileId, triggerName, timestamp, file, cpuUsage, memoryUsage, null); + } + // visible for tests + Mono uploadJfrFile( + UUID profileId, + String triggerName, + long timestamp, + File file, + double cpuUsage, + double memoryUsage, + @Nullable String settingsMoniker) { return uploadFile( - triggerName, timestamp, profileId, file, cpuUsage, memoryUsage, "Profile", "jfr", "jfr"); + triggerName, + timestamp, + profileId, + file, + cpuUsage, + memoryUsage, + "Profile", + "jfr", + "jfr", + settingsMoniker); } @SuppressWarnings("TooManyParameters") // parameter count justified by method complexity @@ -138,6 +159,31 @@ public Mono uploadFile( String artifactKind, String extension, String fileFormat) { + return uploadFile( + triggerName, + timestamp, + profileId, + file, + cpuUsage, + memoryUsage, + artifactKind, + extension, + fileFormat, + null); + } + + @SuppressWarnings("TooManyParameters") // parameter count justified by method complexity + private Mono uploadFile( + String triggerName, + long timestamp, + UUID profileId, + File file, + double cpuUsage, + double memoryUsage, + String artifactKind, + String extension, + String fileFormat, + @Nullable String settingsMoniker) { String appId = appIdSupplier.get(); if (appId == null || appId.isEmpty()) { logger.error("Failed to upload due to lack of appId"); @@ -163,21 +209,25 @@ public Mono uploadFile( String fileId = createId(); String formattedTimestamp = TimestampContract.padNanos(done.getTimeStamp()); - return ServiceProfilerIndex.builder() - .setTriggeredBy(triggerName) - .setFileId(fileId) - .setStampId(done.getStampId()) - .setDataCubeId(UUID.fromString(appId)) - .setTimeStamp(formattedTimestamp) - .setMachineName(uploadContext.getMachineName()) - .setOs(OsPlatformProvider.getOsPlatformDescription()) - .setProcessId(processId) - .setArtifactKind(artifactKind) - .setArtifactId(profileId.toString()) - .setExtension(extension) - .setCpuUsage(cpuUsage) - .setMemoryUsage(memoryUsage) - .build(); + ServiceProfilerIndex.Builder indexBuilder = + ServiceProfilerIndex.builder() + .setTriggeredBy(triggerName) + .setFileId(fileId) + .setStampId(done.getStampId()) + .setDataCubeId(UUID.fromString(appId)) + .setTimeStamp(formattedTimestamp) + .setMachineName(uploadContext.getMachineName()) + .setOs(OsPlatformProvider.getOsPlatformDescription()) + .setProcessId(processId) + .setArtifactKind(artifactKind) + .setArtifactId(profileId.toString()) + .setExtension(extension) + .setCpuUsage(cpuUsage) + .setMemoryUsage(memoryUsage); + if (settingsMoniker != null && !settingsMoniker.trim().isEmpty()) { + indexBuilder.setSettingsMoniker(settingsMoniker.trim()); + } + return indexBuilder.build(); }); } diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java index f1681842127..4c8128f95bb 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java @@ -8,6 +8,8 @@ import com.microsoft.applicationinsights.agent.internal.configuration.Configuration; import com.microsoft.applicationinsights.agent.internal.configuration.GcReportingLevel; import com.microsoft.applicationinsights.agent.internal.profiler.config.ProfilerConfiguration; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedCollectionPlan; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedInstance; import com.microsoft.applicationinsights.agent.internal.telemetry.TelemetryClient; import java.io.File; import java.time.Duration; @@ -17,6 +19,7 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.function.Consumer; @@ -167,6 +170,16 @@ private ProfilingInitializerTestCase( .withLocalConfiguration(localConfiguration(false, true)) .then(userConfiguredTriggersState(false)) .assertThat(ENABLED)); + + tests.add( + new ProfilingInitializerTestCaseBuilder("Matching targeted plan enables profiler") + .then(targetedProfileState("test-role-name", "test-role-instance")) + .assertThat(ENABLED)); + + tests.add( + new ProfilingInitializerTestCaseBuilder("Unmatched targeted plan does not enable profiler") + .then(targetedProfileState("other-role", "test-role-instance")) + .assertThat(NOT_ENABLED)); } @TestFactory @@ -236,6 +249,18 @@ private static ProfilerConfiguration profileNowState( + triggersEnabled); } + private static ProfilerConfiguration targetedProfileState(String role, String instance) { + return userConfiguredTriggersState(false) + .setTargetedCollectionPlan( + new TargetedCollectionPlan() + .setInstances( + Collections.singletonList( + new TargetedInstance().setRole(role).setName(instance))) + .setImmediateProfilingDuration(120) + .setExpiration("2099-08-17T19:00:00.0000000Z") + .setSettingsMoniker("Portal_test")); + } + @SuppressWarnings( "DirectInvocationOnMock") // direct mock invocation is intentional for test setup private static ProfilingInitializer createProfilingInitializer( diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfigurationTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfigurationTest.java index eb889fa33d0..62b3a308fb4 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfigurationTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/config/ProfilerConfigurationTest.java @@ -4,6 +4,7 @@ package com.microsoft.applicationinsights.agent.internal.profiler.config; import com.azure.json.JsonOptions; +import com.azure.json.JsonProviders; import com.azure.json.JsonReader; import com.azure.json.implementation.DefaultJsonReader; import com.fasterxml.jackson.core.JsonProcessingException; @@ -79,4 +80,47 @@ public void testAlertDeserialization() { throw new RuntimeException(e); } } + + @Test + void parsesTargetedCollectionPlan() throws IOException { + String configStr = + "{\"id\":\"an-id\",\"lastModified\":\"2026-07-24T13:58:12.447Z\"," + + "\"enabledLastModified\":\"2026-07-24T13:58:12.447Z\",\"enabled\":true," + + "\"collectionPlan\":\"\",\"targetedCollectionPlan\":{" + + "\"instances\":[{\"role\":\"frontend\",\"name\":\"vm-1\",\"future\":true}]," + + "\"immediateProfilingDuration\":120," + + "\"expiration\":\"2026-08-17T19:00:00.0000000Z\"," + + "\"settingsMoniker\":\"Portal_test\"," + + "\"futureField\":\"ignored\"}}"; + + ProfilerConfiguration configuration; + try (JsonReader reader = JsonProviders.createReader(configStr)) { + configuration = ProfilerConfiguration.fromJson(reader); + } + + TargetedCollectionPlan plan = configuration.getTargetedCollectionPlan(); + Assertions.assertNotNull(plan); + Assertions.assertNull(plan.getRoles()); + Assertions.assertEquals(1, plan.getInstances().size()); + Assertions.assertEquals("frontend", plan.getInstances().get(0).getRole()); + Assertions.assertEquals("vm-1", plan.getInstances().get(0).getName()); + Assertions.assertEquals(120, plan.getImmediateProfilingDuration()); + Assertions.assertEquals("2026-08-17T19:00:00.0000000Z", plan.getExpiration()); + Assertions.assertEquals("Portal_test", plan.getSettingsMoniker()); + } + + @Test + void targetedCollectionPlanIsOptional() throws IOException { + String configStr = + "{\"id\":\"an-id\",\"lastModified\":\"2026-07-24T13:58:12.447Z\"," + + "\"enabledLastModified\":\"2026-07-24T13:58:12.447Z\",\"enabled\":true," + + "\"collectionPlan\":\"\"}"; + + ProfilerConfiguration configuration; + try (JsonReader reader = JsonProviders.createReader(configStr)) { + configuration = ProfilerConfiguration.fromJson(reader); + } + + Assertions.assertNull(configuration.getTargetedCollectionPlan()); + } } diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClientTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClientTest.java new file mode 100644 index 00000000000..5ffdd5a796c --- /dev/null +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClientTest.java @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.agent.internal.profiler.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.test.http.MockHttpResponse; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class ServiceProfilerClientTest { + + @Test + void fallsBackToLegacyFeatureVersionAndCachesResult() throws MalformedURLException { + List requestUrls = new ArrayList<>(); + HttpClient httpClient = + request -> { + requestUrls.add(request.getUrl().toString()); + String body = + request.getUrl().getQuery().contains("featureVersion=2.0.0") + ? "{\"id\":\"00000000-0000-0000-0000-000000000000\",\"enabled\":false}" + : settingsJson(true); + return Mono.just( + new MockHttpResponse(request, 200, body.getBytes(StandardCharsets.UTF_8))); + }; + ServiceProfilerClient client = newServiceProfilerClient(httpClient); + + assertThat(client.getSettings(new Date(0)).block().isEnabled()).isTrue(); + assertThat(client.getSettings(new Date(0)).block().isEnabled()).isTrue(); + + assertThat(requestUrls) + .containsExactly( + "https://agent.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=2.0.0", + "https://agent.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=1.0.0", + "https://agent.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=1.0.0"); + } + + @Test + void keepsTargetedFeatureVersionWhenSupported() throws MalformedURLException { + List requestUrls = new ArrayList<>(); + HttpClient httpClient = + request -> { + requestUrls.add(request.getUrl().toString()); + return Mono.just( + new MockHttpResponse( + request, 200, settingsJson(false).getBytes(StandardCharsets.UTF_8))); + }; + ServiceProfilerClient client = newServiceProfilerClient(httpClient); + + assertThat(client.getSettings(new Date(0)).block().isEnabled()).isFalse(); + assertThat(client.getSettings(new Date(0)).block().isEnabled()).isFalse(); + + assertThat(requestUrls).allMatch(url -> url.contains("featureVersion=2.0.0")); + } + + private static ServiceProfilerClient newServiceProfilerClient(HttpClient httpClient) + throws MalformedURLException { + return new ServiceProfilerClient( + new URL("https://agent.azureserviceprofiler.net/"), + "00000000-0000-0000-0000-000000000000", + new HttpPipelineBuilder().httpClient(httpClient).build()); + } + + private static String settingsJson(boolean enabled) { + return "{\"id\":\"11111111-1111-1111-1111-111111111111\"," + + "\"lastModified\":\"2026-09-02T16:00:00Z\"," + + "\"enabledLastModified\":\"2026-09-02T16:00:00Z\"," + + "\"enabled\":" + + enabled + + "}"; + } +} diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParserTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParserTest.java index d26cfd63881..524e0a3ad1e 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParserTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/triggers/AlertConfigParserTest.java @@ -5,6 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.microsoft.applicationinsights.agent.internal.profiler.config.ProfilerConfiguration; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedCollectionPlan; +import com.microsoft.applicationinsights.agent.internal.profiler.config.TargetedInstance; import com.microsoft.applicationinsights.alerting.aiconfig.AlertingConfig; import com.microsoft.applicationinsights.alerting.config.AlertConfiguration; import com.microsoft.applicationinsights.alerting.config.AlertMetricType; @@ -12,7 +15,12 @@ import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration; import com.microsoft.applicationinsights.alerting.config.CollectionPlanConfiguration.EngineMode; import com.microsoft.applicationinsights.alerting.config.DefaultConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedCollectionPlanConfiguration; +import com.microsoft.applicationinsights.alerting.config.TargetedInstanceConfiguration; +import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Test; @@ -127,4 +135,162 @@ void requestTriggerIsBuilt() { .setRequestTrigger(requestTrigger) .build()); } + + @Test + void targetedRolesAreParsedFaithfully() { + ProfilerConfiguration profilerConfiguration = + targetedConfiguration(targetedPlan().setRoles(Arrays.asList(" frontend ", "backend"))); + + TargetedCollectionPlanConfiguration plan = + AlertConfigParser.toAlertingConfig(profilerConfiguration) + .getTargetedCollectionPlanConfiguration(); + + assertThat(plan).isNotNull(); + assertThat(plan.getRoles()).containsExactly(" frontend ", "backend"); + assertThat(plan.getImmediateProfilingDurationSeconds()).isEqualTo(120); + assertThat(plan.getExpiration()).isEqualTo(Instant.parse("2099-08-17T19:00:00Z")); + assertThat(plan.getSettingsMoniker()).isEqualTo("Portal_test"); + } + + @Test + void targetedInstancesAreParsedFaithfully() { + ProfilerConfiguration profilerConfiguration = + targetedConfiguration( + targetedPlan() + .setInstances( + Collections.singletonList( + new TargetedInstance().setRole("frontend").setName("instance-1")))); + + TargetedCollectionPlanConfiguration plan = + AlertConfigParser.toAlertingConfig(profilerConfiguration) + .getTargetedCollectionPlanConfiguration(); + + assertThat(plan).isNotNull(); + assertThat(plan.getInstances()) + .containsExactly(TargetedInstanceConfiguration.create("frontend", "instance-1")); + } + + @Test + void malformedLegacyPlanDoesNotBlockTargetedPlan() { + ProfilerConfiguration profilerConfiguration = + targetedConfiguration(targetedPlan().setRoles(Collections.singletonList("frontend"))) + .setCollectionPlan( + "--single --mode immediate --immediate-profiling-duration invalid" + + " --expiration invalid --settings-moniker legacy"); + + AlertingConfiguration config = AlertConfigParser.toAlertingConfig(profilerConfiguration); + + assertThat(config.getCollectionPlanConfiguration().isSingle()).isFalse(); + assertThat(config.getTargetedCollectionPlanConfiguration()).isNotNull(); + assertThat( + config.hasAnEnabledTrigger( + "frontend", "instance-1", Instant.parse("2099-01-01T00:00:00Z"))) + .isTrue(); + } + + @Test + void invalidTargetedPlansFailClosed() { + TargetedCollectionPlan mixedPlan = + targetedPlan() + .setRoles(Collections.singletonList("frontend")) + .setInstances( + Collections.singletonList( + new TargetedInstance().setRole("frontend").setName("instance-1"))); + ProfilerConfiguration mixedConfiguration = targetedConfiguration(mixedPlan); + + AlertingConfiguration mixedAlertingConfig = + AlertConfigParser.toAlertingConfig(mixedConfiguration); + assertThat(mixedAlertingConfig.getTargetedCollectionPlanConfiguration()).isNotNull(); + assertThat( + mixedAlertingConfig.hasAnEnabledTrigger( + "frontend", "instance-1", Instant.parse("2099-01-01T00:00:00Z"))) + .isFalse(); + + ProfilerConfiguration mixedLegacyConfiguration = + targetedConfiguration(targetedPlan().setRoles(Collections.singletonList("frontend"))) + .setCollectionPlan( + "--single --mode immediate --immediate-profiling-duration 120" + + " --expiration 5249157885138288517 --settings-moniker legacy"); + + AlertingConfiguration combinedConfig = + AlertConfigParser.toAlertingConfig(mixedLegacyConfiguration); + assertThat(combinedConfig.getCollectionPlanConfiguration().isSingle()).isTrue(); + assertThat(combinedConfig.getTargetedCollectionPlanConfiguration()).isNotNull(); + assertThat( + combinedConfig.hasAnEnabledTrigger( + "frontend", "instance-1", Instant.parse("2099-01-01T00:00:00Z"))) + .isTrue(); + + ProfilerConfiguration invalidTargetedWithLegacy = + targetedConfiguration(mixedPlan) + .setCollectionPlan( + "--single --mode immediate --immediate-profiling-duration 120" + + " --expiration 5249157885138288517 --settings-moniker legacy"); + assertThat( + AlertConfigParser.toAlertingConfig(invalidTargetedWithLegacy) + .hasAnEnabledTrigger("frontend", "instance-1", Instant.EPOCH)) + .isFalse(); + } + + @Test + void targetedPlansWithNullValuesFailClosed() { + assertTargetedPlanInvalid(targetedPlan().setInstances(Collections.singletonList(null))); + assertTargetedPlanInvalid( + targetedPlan() + .setInstances( + Collections.singletonList(new TargetedInstance().setRole(null).setName(null)))); + assertTargetedPlanInvalid( + targetedPlan() + .setRoles(Collections.singletonList("frontend")) + .setExpiration(null) + .setSettingsMoniker(null)); + } + + @Test + void targetedPlanValidatesDurationExpirationAndIdentity() { + assertTargetedPlanInvalid( + targetedPlan() + .setRoles(Collections.singletonList("frontend")) + .setImmediateProfilingDuration(361)); + assertTargetedPlanInvalid( + targetedPlan() + .setRoles(Collections.singletonList("frontend")) + .setExpiration("not-a-timestamp")); + + TargetedCollectionPlanConfiguration plan = + AlertConfigParser.toAlertingConfig( + targetedConfiguration( + targetedPlan().setRoles(Collections.singletonList("frontend")))) + .getTargetedCollectionPlanConfiguration(); + assertThat(plan).isNotNull(); + assertThat( + AlertConfigParser.toAlertingConfig( + targetedConfiguration( + targetedPlan().setRoles(Collections.singletonList("frontend")))) + .hasAnEnabledTrigger(null, "instance-1", Instant.parse("2099-01-01T00:00:00Z"))) + .isFalse(); + } + + private static void assertTargetedPlanInvalid(TargetedCollectionPlan plan) { + TargetedCollectionPlanConfiguration parsedPlan = + AlertConfigParser.toAlertingConfig(targetedConfiguration(plan)) + .getTargetedCollectionPlanConfiguration(); + assertThat(parsedPlan).isNotNull(); + assertThat( + AlertConfigParser.toAlertingConfig(targetedConfiguration(plan)) + .hasAnEnabledTrigger( + "frontend", "instance-1", Instant.parse("2099-01-01T00:00:00Z"))) + .isFalse(); + } + + private static ProfilerConfiguration targetedConfiguration(TargetedCollectionPlan plan) { + return new ProfilerConfiguration().setCollectionPlan("").setTargetedCollectionPlan(plan); + } + + private static TargetedCollectionPlan targetedPlan() { + return new TargetedCollectionPlan() + .setImmediateProfilingDuration(120) + .setExpiration("2099-08-17T19:00:00.0000000Z") + .setSettingsMoniker("Portal_test"); + } } diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadServiceTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadServiceTest.java index 2f8dd7873a5..c8c605bc3ba 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadServiceTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/upload/UploadServiceTest.java @@ -46,7 +46,9 @@ void uploadFileGoodPathReturnsExpectedResponse() throws IOException { "a-role-name"); ServiceProfilerIndex serviceProfilerIndex = - uploadService.uploadJfrFile(profileId, "a-trigger", 321, tmpFile, 0.0, 0.0).block(); + uploadService + .uploadJfrFile(profileId, "a-trigger", 321, tmpFile, 0.0, 0.0, "Portal_test") + .block(); assertThat( serviceProfilerIndex @@ -71,6 +73,12 @@ void uploadFileGoodPathReturnsExpectedResponse() throws IOException { .getProperties() .get(ServiceProfilerIndex.Builder.SERVICE_PROFILER_DATACUBE_PROPERTY_NAME)) .isEqualTo(appId.toString()); + + assertThat( + serviceProfilerIndex + .getProperties() + .get(ServiceProfilerIndex.Builder.SERVICE_PROFILER_SETTINGS_MONIKER_PROPERTY_NAME)) + .isEqualTo("Portal_test"); } private static File createFakeJfrFile() throws IOException { diff --git a/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.badServiceResponseDoesNotProvideReturn.json b/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.badServiceResponseDoesNotProvideReturn.json index 60b61ce0ab5..caf084aaa04 100644 --- a/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.badServiceResponseDoesNotProvideReturn.json +++ b/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.badServiceResponseDoesNotProvideReturn.json @@ -1,7 +1,7 @@ { "networkCallRecords" : [ { "Method" : "GET", - "Uri" : "https://REDACTED.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=1.0.0", + "Uri" : "https://REDACTED.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=2.0.0", "Headers" : { }, "Response" : { "Transfer-Encoding" : "chunked", diff --git a/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.pullSettings.json b/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.pullSettings.json index 7fea40eddc1..35d0c65ac52 100644 --- a/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.pullSettings.json +++ b/agent/agent-tooling/src/test/resources/session-records/ConfigServiceTest.pullSettings.json @@ -1,7 +1,7 @@ { "networkCallRecords" : [ { "Method" : "GET", - "Uri" : "https://REDACTED.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=1.0.0", + "Uri" : "https://REDACTED.azureserviceprofiler.net/api/profileragent/v4/settings?iKey=00000000-0000-0000-0000-000000000000&oldTimestamp=1970-01-01T00:00:00.0Z&featureVersion=2.0.0", "Headers" : { }, "Response" : { "Transfer-Encoding" : "chunked", diff --git a/smoke-tests/apps/DiagnosticExtension/src/smokeTest/java/com/microsoft/applicationinsights/smoketest/JavaProfileConfigTest.java b/smoke-tests/apps/DiagnosticExtension/src/smokeTest/java/com/microsoft/applicationinsights/smoketest/JavaProfileConfigTest.java index 05af416d969..8e10ed8e242 100644 --- a/smoke-tests/apps/DiagnosticExtension/src/smokeTest/java/com/microsoft/applicationinsights/smoketest/JavaProfileConfigTest.java +++ b/smoke-tests/apps/DiagnosticExtension/src/smokeTest/java/com/microsoft/applicationinsights/smoketest/JavaProfileConfigTest.java @@ -79,4 +79,26 @@ static class JavaProfilerManualProfileTest extends JavaProfileConfigTest { super(testing, true); } } + + @Environment(JAVA_11) + static class JavaProfilerTargetedMatchingTest extends JavaProfileConfigTest { + @RegisterExtension + static final SmokeTestExtension testing = + BASE_BUILDER.setProfilerEndpoint(ProfilerState.targetedMatching).build(); + + JavaProfilerTargetedMatchingTest() { + super(testing, true); + } + } + + @Environment(JAVA_11) + static class JavaProfilerTargetedUnmatchedTest extends JavaProfileConfigTest { + @RegisterExtension + static final SmokeTestExtension testing = + BASE_BUILDER.setProfilerEndpoint(ProfilerState.targetedUnmatched).build(); + + JavaProfilerTargetedUnmatchedTest() { + super(testing, false); + } + } } diff --git a/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/MockedProfilerSettingsServlet.java b/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/MockedProfilerSettingsServlet.java index 8225b48507c..aed2a8f9c41 100644 --- a/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/MockedProfilerSettingsServlet.java +++ b/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/MockedProfilerSettingsServlet.java @@ -99,6 +99,44 @@ public class MockedProfilerSettingsServlet extends HttpServlet { + "\",\n" + " \"memoryTriggerConfiguration\" : \"--memory-threshold 80 --memory-trigger-profilingDuration 120 --memory-trigger-cooldown 14400 --memory-trigger-enabled true\"\n" + "}\n"); + + CONFIGS.put( + ProfilerState.targetedMatching, + targetedConfig(now, Instant.now().plusSeconds(3600), "testrolename", "testroleinstance")); + CONFIGS.put( + ProfilerState.targetedUnmatched, + targetedConfig(now, Instant.now().plusSeconds(3600), "other-role", "testroleinstance")); + } + + private static String targetedConfig( + String now, Instant expiration, String roleName, String roleInstance) { + return "{\n" + + " \"agentConcurrency\" : 0,\n" + + " \"collectionPlan\" : \"\",\n" + + " \"cpuTriggerConfiguration\" : \"--cpu-threshold 80 --cpu-trigger-profilingDuration 120 --cpu-trigger-cooldown 14400 --cpu-trigger-enabled false\",\n" + + " \"defaultConfiguration\" : null,\n" + + " \"enabled\" : true,\n" + + " \"enabledLastModified\" : \"" + + now + + "\",\n" + + " \"id\" : \"an-id\",\n" + + " \"lastModified\" : \"" + + now + + "\",\n" + + " \"memoryTriggerConfiguration\" : \"--memory-threshold 80 --memory-trigger-profilingDuration 120 --memory-trigger-cooldown 14400 --memory-trigger-enabled false\",\n" + + " \"targetedCollectionPlan\" : {\n" + + " \"instances\" : [{ \"role\" : \"" + + roleName + + "\", \"name\" : \"" + + roleInstance + + "\" }],\n" + + " \"immediateProfilingDuration\" : 1,\n" + + " \"expiration\" : \"" + + expiration + + "\",\n" + + " \"settingsMoniker\" : \"Portal_targeted-smoke\"\n" + + " }\n" + + "}\n"; } private static long toSeconds(Instant time) { diff --git a/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/ProfilerState.java b/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/ProfilerState.java index 94d38ac46e1..efdaee76f6d 100644 --- a/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/ProfilerState.java +++ b/smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/ProfilerState.java @@ -7,5 +7,7 @@ public enum ProfilerState { unconfigured, configuredEnabled, configuredDisabled, - manualprofile + manualprofile, + targetedMatching, + targetedUnmatched } From adff2bb818941e6dc5bb286df888403c82553a80 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:53:26 +0000 Subject: [PATCH 2/7] Fixes from review --- .../agent/internal/profiler/Profiler.java | 15 +++++- .../profiler/ProfilingInitializer.java | 16 ++++++ .../profiler/ProfilingInitializerTest.java | 53 +++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) 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 d6e2bab02b8..41e15d61887 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 @@ -208,7 +208,7 @@ public void updateConfiguration(ProfilerConfiguration newConfig) { void profileAndUpload(AlertBreach alertBreach, Duration duration, UploadListener uploadListener) { Instant recordingStart = timeSource.getNow(); if (continuousProfilingEnabled) { - captureContinuousRecording(alertBreach, recordingStart, uploadListener); + captureContinuousRecording(alertBreach, recordingStart, duration, uploadListener); return; } executeProfile( @@ -266,7 +266,10 @@ public boolean isContinuousRecordingRunning() { @SuppressWarnings( "CatchingUnchecked") // catching unchecked exception is necessary for proper error handling private void captureContinuousRecording( - AlertBreach alertBreach, Instant recordingEnd, UploadListener uploadListener) { + AlertBreach alertBreach, + Instant recordingEnd, + Duration requestedDuration, + UploadListener uploadListener) { File dumpFile; Instant bufferStart; synchronized (activeRecordingLock) { @@ -297,6 +300,14 @@ private void captureContinuousRecording( (continuousRecordingStart != null && continuousRecordingStart.isAfter(maxAgeStart)) ? continuousRecordingStart : maxAgeStart; + Duration capturedDuration = Duration.between(bufferStart, recordingEnd); + if (!capturedDuration.equals(requestedDuration)) { + logger.info( + "Continuous profiling captures the retained buffer; requested duration was {} seconds," + + " actual captured duration is {} seconds", + requestedDuration.getSeconds(), + capturedDuration.getSeconds()); + } try { dumpFile = createJfrFile(bufferStart, recordingEnd); 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 739122ba8d6..5e5ed616bd1 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 @@ -7,6 +7,7 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.policy.DefaultRedirectStrategy; import com.azure.core.http.policy.RedirectPolicy; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.SystemInformation; import com.azure.monitor.opentelemetry.autoconfigure.implementation.utils.ThreadPoolUtils; import com.microsoft.applicationinsights.agent.internal.common.FriendlyException; @@ -26,6 +27,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.HashSet; +import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -91,6 +93,20 @@ public static ProfilingInitializer initialize( String roleInstance, TelemetryClient telemetryClient) { + Map telemetryTags = + telemetryClient.newMessageTelemetryBuilder().build().getTags(); + if (telemetryTags != null) { + String resolvedRoleName = telemetryTags.get(ContextTagKeys.AI_CLOUD_ROLE.toString()); + String resolvedRoleInstance = + telemetryTags.get(ContextTagKeys.AI_CLOUD_ROLE_INSTANCE.toString()); + if (resolvedRoleName != null) { + roleName = resolvedRoleName; + } + if (resolvedRoleInstance != null) { + roleInstance = resolvedRoleInstance; + } + } + ProfilingInitializer profilingInitializer = new ProfilingInitializer( SystemInformation.getProcessId(), diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java index 4c8128f95bb..f3f673c02ee 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilingInitializerTest.java @@ -5,6 +5,7 @@ import com.azure.monitor.opentelemetry.autoconfigure.implementation.builders.MessageTelemetryBuilder; import com.azure.monitor.opentelemetry.autoconfigure.implementation.configuration.ConnectionString; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; import com.microsoft.applicationinsights.agent.internal.configuration.Configuration; import com.microsoft.applicationinsights.agent.internal.configuration.GcReportingLevel; import com.microsoft.applicationinsights.agent.internal.profiler.config.ProfilerConfiguration; @@ -34,6 +35,8 @@ private static class ProfilingInitializerTestCaseBuilder { final String name; final List configurations = new ArrayList<>(); Configuration.ProfilerConfiguration localConfiguration = defaultLocalConfiguration(); + String telemetryRoleName; + String telemetryRoleInstance; private ProfilingInitializerTestCaseBuilder(String name) { this.name = name; @@ -50,8 +53,21 @@ ProfilingInitializerTestCaseBuilder withLocalConfiguration( return this; } + ProfilingInitializerTestCaseBuilder withTelemetryIdentity( + String roleName, String roleInstance) { + telemetryRoleName = roleName; + telemetryRoleInstance = roleInstance; + return this; + } + ProfilingInitializerTestCase assertThat(Consumer assertion) { - return new ProfilingInitializerTestCase(name, configurations, localConfiguration, assertion); + return new ProfilingInitializerTestCase( + name, + configurations, + localConfiguration, + telemetryRoleName, + telemetryRoleInstance, + assertion); } } @@ -59,16 +75,22 @@ private static class ProfilingInitializerTestCase { final String name; final List configurations; final Configuration.ProfilerConfiguration localConfiguration; + final String telemetryRoleName; + final String telemetryRoleInstance; final Consumer assertion; private ProfilingInitializerTestCase( String name, List configurations, Configuration.ProfilerConfiguration localConfiguration, + String telemetryRoleName, + String telemetryRoleInstance, Consumer assertion) { this.name = name; this.configurations = configurations; this.localConfiguration = localConfiguration; + this.telemetryRoleName = telemetryRoleName; + this.telemetryRoleInstance = telemetryRoleInstance; this.assertion = assertion; } } @@ -180,6 +202,19 @@ private ProfilingInitializerTestCase( new ProfilingInitializerTestCaseBuilder("Unmatched targeted plan does not enable profiler") .then(targetedProfileState("other-role", "test-role-instance")) .assertThat(NOT_ENABLED)); + + tests.add( + new ProfilingInitializerTestCaseBuilder( + "Resource-derived service identity enables targeted plan") + .withTelemetryIdentity("[production]/orders", "pod-1") + .then(targetedProfileState("[production]/orders", "pod-1")) + .assertThat(ENABLED)); + + tests.add( + new ProfilingInitializerTestCaseBuilder("AKS-derived identity enables targeted plan") + .withTelemetryIdentity("orders-deployment", "orders-pod-1") + .then(targetedProfileState("orders-deployment", "orders-pod-1")) + .assertThat(ENABLED)); } @TestFactory @@ -191,7 +226,10 @@ public Collection runTests() { testCase.name, () -> { ProfilingInitializer profiler = - createProfilingInitializer(testCase.localConfiguration); + createProfilingInitializer( + testCase.localConfiguration, + testCase.telemetryRoleName, + testCase.telemetryRoleInstance); testCase.configurations.forEach(profiler::applyConfiguration); @@ -264,9 +302,18 @@ private static ProfilerConfiguration targetedProfileState(String role, String in @SuppressWarnings( "DirectInvocationOnMock") // direct mock invocation is intentional for test setup private static ProfilingInitializer createProfilingInitializer( - Configuration.ProfilerConfiguration localConfiguration) { + Configuration.ProfilerConfiguration localConfiguration, + String telemetryRoleName, + String telemetryRoleInstance) { TelemetryClient client = Mockito.mock(TelemetryClient.class); MessageTelemetryBuilder messageTelemetryBuilder = MessageTelemetryBuilder.create(); + if (telemetryRoleName != null) { + messageTelemetryBuilder.addTag(ContextTagKeys.AI_CLOUD_ROLE.toString(), telemetryRoleName); + } + if (telemetryRoleInstance != null) { + messageTelemetryBuilder.addTag( + ContextTagKeys.AI_CLOUD_ROLE_INSTANCE.toString(), telemetryRoleInstance); + } Mockito.when(client.newMessageTelemetryBuilder()).thenReturn(messageTelemetryBuilder); Mockito.when(client.getConnectionString()) .thenReturn( From def51de73bf0b1e8bc330412786aa7f08b39af6a Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:34 +0000 Subject: [PATCH 3/7] Improve targeted profiling behavior Align heartbeat identity with profiler targeting and honor targeted profile durations while continuous profiling is enabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../alerting/alert/AlertBreach.java | 7 +- .../alerting/AlertingSubsystem.java | 7 +- .../alerting/AlertingSubsystemTest.java | 2 + .../internal/init/AfterAgentListener.java | 8 +- .../internal/init/RuntimeConfigurator.java | 12 +- .../agent/internal/init/SecondEntryPoint.java | 43 ++-- .../agent/internal/profiler/Profiler.java | 179 +++++++++++----- .../triggers/AlertingSubsystemInit.java | 31 +-- .../internal/telemetry/TelemetryClient.java | 8 + .../ProfilerContinuousProfilingTest.java | 196 +++++++++++++++--- .../telemetry/TelemetryClientTest.java | 35 ++++ docs/README.md | 8 +- 12 files changed, 403 insertions(+), 133 deletions(-) create mode 100644 agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClientTest.java diff --git a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java index 84199fc845d..6ce27707c5d 100644 --- a/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java +++ b/agent/agent-profiler/agent-alerting-api/src/main/java/com/microsoft/applicationinsights/alerting/alert/AlertBreach.java @@ -72,13 +72,16 @@ public AlertBreach setProfileId(String profileId) { @Nullable public abstract String getSettingsMoniker(); + public abstract boolean isTargeted(); + public abstract Builder toBuilder(); public static AlertBreach.Builder builder() { return new AutoValue_AlertBreach.Builder() .setCpuMetric(0) .setMemoryUsage(0) - .setProfileId(UUID.randomUUID().toString()); + .setProfileId(UUID.randomUUID().toString()) + .setTargeted(false); } @Override @@ -122,6 +125,8 @@ public abstract static class Builder implements JsonSerializable { public abstract Builder setSettingsMoniker(@Nullable String settingsMoniker); + public abstract Builder setTargeted(boolean targeted); + public abstract AlertBreach build(); @Override 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 ef11cd622d8..35559202ecd 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 @@ -222,7 +222,7 @@ private void evaluateCollectionPlanTrigger(AlertingConfiguration alertConfig) { if (shouldTrigger) { dispatchManualAlert( - config.getImmediateProfilingDurationSeconds(), config.getSettingsMoniker()); + config.getImmediateProfilingDurationSeconds(), config.getSettingsMoniker(), false); } } @@ -243,11 +243,11 @@ private void evaluateTargetedCollectionPlanTrigger(AlertingConfiguration alertCo String settingsMoniker = config.getSettingsMoniker(); if (settingsMoniker != null && executedMonikers.tryMarkExecuted(settingsMoniker, timeSource.getNow())) { - dispatchManualAlert(config.getImmediateProfilingDurationSeconds(), settingsMoniker); + dispatchManualAlert(config.getImmediateProfilingDurationSeconds(), settingsMoniker, true); } } - private void dispatchManualAlert(int durationSeconds, String settingsMoniker) { + private void dispatchManualAlert(int durationSeconds, String settingsMoniker, boolean targeted) { AlertBreach alertBreach = AlertBreach.builder() .setType(AlertMetricType.MANUAL) @@ -262,6 +262,7 @@ private void dispatchManualAlert(int durationSeconds, String settingsMoniker) { .setCpuMetric(0) .setMemoryUsage(0) .setSettingsMoniker(settingsMoniker) + .setTargeted(targeted) .build(); alertHandler.accept(alertBreach); } 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 5e8f08fca70..b2b0418fee1 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 @@ -125,6 +125,7 @@ void manualAlertWorks() { assertThat(called.get().getType()).isEqualTo(AlertMetricType.MANUAL); assertThat(called.get().getSettingsMoniker()).isEqualTo("a-settings-moniker"); + assertThat(called.get().isTargeted()).isFalse(); } @Test @@ -199,6 +200,7 @@ void targetedAlertTriggersOnlyForMatchingIdentity() { assertThat(matchingBreach.get()).isNotNull(); assertThat(matchingBreach.get().getType()).isEqualTo(AlertMetricType.MANUAL); assertThat(matchingBreach.get().getSettingsMoniker()).isEqualTo("Portal_test"); + assertThat(matchingBreach.get().isTargeted()).isTrue(); assertThat(unmatchedBreach.get()).isNull(); } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java index 7318ff91165..9fbda8fb4e9 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java @@ -20,6 +20,9 @@ public class AfterAgentListener implements AgentListener { private static final Logger logger = LoggerFactory.getLogger(AfterAgentListener.class); @Override + @SuppressWarnings( + "CatchingUnchecked") // optional profiler failures must not affect the instrumented + // application public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) { // only safe now to make HTTPS calls because Java SSL classes // trigger loading of java.util.logging (starting with Java 8u231) @@ -30,6 +33,7 @@ public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetr PerformanceCounterInitializer.initialize(configuration); TelemetryClient telemetryClient = TelemetryClient.getActive(); + SecondEntryPoint.startHeartbeat(configuration, telemetryClient); if (configuration.preview.browserSdkLoader.enabled && telemetryClient != null && telemetryClient.getConnectionString() != null) { @@ -46,8 +50,8 @@ public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetr configuration.role.name, configuration.role.instance, TelemetryClient.getActive()); - } catch (RuntimeException e) { - logger.warn("Failed to initialize profiler", e); + } catch (Throwable t) { + logger.warn("Failed to initialize profiler", t); } } } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java index 2109d0cb9cb..96c2bb196f4 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java @@ -110,6 +110,9 @@ public RuntimeConfiguration getCurrentConfigCopy() { return copy(currentConfig); } + @SuppressWarnings( + "CatchingUnchecked") // optional profiler failures must not affect the instrumented + // application public void apply(RuntimeConfiguration runtimeConfig) { logger.debug("Applying runtime configuration"); @@ -151,8 +154,9 @@ public void apply(RuntimeConfiguration runtimeConfig) { runtimeConfig.role.name, runtimeConfig.role.instance, telemetryClient); - } catch (RuntimeException e) { - logger.warn("Failed to initialize profiler", e); + } catch (Throwable t) { + profilerStarted.set(false); + logger.warn("Failed to initialize profiler", t); } } else { logger.debug("Profiler has already been initialized."); @@ -167,7 +171,9 @@ public void apply(RuntimeConfiguration runtimeConfig) { long intervalSeconds = Math.min(runtimeConfig.heartbeatIntervalSeconds, MINUTES.toSeconds(15)); HeartbeatExporter.start( - intervalSeconds, telemetryClient::populateDefaults, heartbeatTelemetryItemsConsumer); + intervalSeconds, + telemetryClient::populateDefaultsForHeartbeat, + heartbeatTelemetryItemsConsumer); } else { logger.debug("Heartbeat has already started."); } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java index ee8323465be..07652b70dbc 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java @@ -146,23 +146,7 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) { .build(); Consumer> heartbeatTelemetryItemConsumer = - telemetryItems -> { - for (TelemetryItem telemetryItem : telemetryItems) { - TelemetryObservers.INSTANCE - .getObservers() - .forEach(consumer -> consumer.accept(telemetryItem)); - telemetryClient.getMetricsBatchItemProcessor().trackAsync(telemetryItem); - } - }; - - if (telemetryClient.getConnectionString() != null) { - startupLogger.verbose("connection string is not null, start HeartbeatExporter"); - // interval longer than 15 minutes is not allowed since we use this data for usage telemetry - long intervalSeconds = - Math.min(configuration.heartbeat.intervalSeconds, MINUTES.toSeconds(15)); - HeartbeatExporter.start( - intervalSeconds, telemetryClient::populateDefaults, heartbeatTelemetryItemConsumer); - } + createHeartbeatTelemetryItemConsumer(telemetryClient); TelemetryClient.setActive(telemetryClient); @@ -321,6 +305,31 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) { }); } + static void startHeartbeat(Configuration configuration, TelemetryClient telemetryClient) { + if (telemetryClient.getConnectionString() == null) { + return; + } + startupLogger.verbose("connection string is not null, start HeartbeatExporter"); + // interval longer than 15 minutes is not allowed since we use this data for usage telemetry + long intervalSeconds = Math.min(configuration.heartbeat.intervalSeconds, MINUTES.toSeconds(15)); + HeartbeatExporter.start( + intervalSeconds, + telemetryClient::populateDefaultsForHeartbeat, + createHeartbeatTelemetryItemConsumer(telemetryClient)); + } + + private static Consumer> createHeartbeatTelemetryItemConsumer( + TelemetryClient telemetryClient) { + return telemetryItems -> { + for (TelemetryItem telemetryItem : telemetryItems) { + TelemetryObservers.INSTANCE + .getObservers() + .forEach(consumer -> consumer.accept(telemetryItem)); + telemetryClient.getMetricsBatchItemProcessor().trackAsync(telemetryItem); + } + }; + } + private static LogRecordProcessor wrapBatchLogRecordProcessor( LogRecordProcessor logRecordProcessor, Configuration configuration) { List logRecordProcessors = getLogRecordProcessors(configuration); 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 41e15d61887..46a3ad3c9de 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 @@ -25,7 +25,6 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -206,17 +205,33 @@ public void updateConfiguration(ProfilerConfiguration newConfig) { // visible for tests void profileAndUpload(AlertBreach alertBreach, Duration duration, UploadListener uploadListener) { + profileAndUpload(alertBreach, duration, uploadListener, () -> {}); + } + + private void profileAndUpload( + AlertBreach alertBreach, + Duration duration, + UploadListener uploadListener, + Runnable diagnosticAction) { Instant recordingStart = timeSource.getNow(); - if (continuousProfilingEnabled) { - captureContinuousRecording(alertBreach, recordingStart, duration, uploadListener); + if (usesContinuousRecordingSnapshot(alertBreach)) { + captureContinuousRecording( + alertBreach, recordingStart, duration, uploadListener, diagnosticAction); return; } executeProfile( alertBreach.getType(), duration, - uploadNewRecording(alertBreach, recordingStart, uploadListener)); + uploadNewRecording(alertBreach, recordingStart, uploadListener), + diagnosticAction); } + private boolean usesContinuousRecordingSnapshot(AlertBreach alertBreach) { + return continuousProfilingEnabled && !alertBreach.isTargeted(); + } + + @SuppressWarnings( + "CatchingUnchecked") // profiler failures must never escape into the instrumented application private void startContinuousRecordingIfEnabled() { if (!continuousProfilingEnabled) { return; @@ -225,6 +240,7 @@ private void startContinuousRecordingIfEnabled() { if (continuousRecording != null) { return; } + Recording newRecording = null; try { // A continuous recording uses a circular buffer bounded by maxAge and no duration, so it // runs indefinitely while only retaining the most recent window of data on disk. @@ -236,16 +252,18 @@ private void startContinuousRecordingIfEnabled() { .maxSize(Long.toString(CONTINUOUS_PROFILING_MAX_SIZE_BYTES)) .disk("true") .build(); - continuousRecording = createRecording(recordingOptions, continuousRecordingConfiguration); - continuousRecording.start(); + newRecording = createRecording(recordingOptions, continuousRecordingConfiguration); + newRecording.start(); + continuousRecording = newRecording; continuousRecordingStart = timeSource.getNow(); logger.info( "Started continuous JFR recording with circular buffer maxAge of {} seconds and maxSize" + " of {} bytes", continuousProfilingMaxAge.getSeconds(), CONTINUOUS_PROFILING_MAX_SIZE_BYTES); - } catch (IOException | JfrConnectionException e) { - logger.error("Failed to start continuous JFR recording", e); + } catch (Throwable t) { + logger.error("Failed to start continuous JFR recording", t); + closeRecordingAfterFailure(newRecording); continuousRecording = null; continuousRecordingStart = null; } @@ -269,7 +287,8 @@ private void captureContinuousRecording( AlertBreach alertBreach, Instant recordingEnd, Duration requestedDuration, - UploadListener uploadListener) { + UploadListener uploadListener, + Runnable diagnosticAction) { File dumpFile; Instant bufferStart; synchronized (activeRecordingLock) { @@ -277,6 +296,11 @@ private void captureContinuousRecording( logger.warn("Profile requested but continuous recording is not running, ignoring request."); return; } + if (activeRecording != null) { + logger.warn( + "Profile requested, but an on-demand profile is already in progress, ignoring request."); + return; + } // Enforce global cooldown across all trigger sources if (globalCooldownSeconds > 0 && timeSource.getNow().isBefore(globalCooldownUntil)) { @@ -287,6 +311,8 @@ private void captureContinuousRecording( return; } + runDiagnosticAction(diagnosticAction); + // A live circular buffer can only be dumped in its entirety; the JFR connection only supports // streaming a sub-window from a stopped recording, so a shorter portal-/JMX-configured // profile @@ -311,8 +337,8 @@ private void captureContinuousRecording( try { dumpFile = createJfrFile(bufferStart, recordingEnd); - } catch (IOException e) { - logger.error("Failed to create jfr file", e); + } catch (Throwable t) { + logger.error("Failed to create jfr file", t); return; } @@ -320,11 +346,9 @@ private void captureContinuousRecording( // Dump the current state of the circular buffer, capturing up to maxAge of data. The // continuous recording keeps running so future requests can be serviced immediately. continuousRecording.dump(dumpFile.getAbsolutePath()); - } catch (IOException | JfrConnectionException e) { - logger.error("Failed to dump continuous recording", e); - if (dumpFile.exists() && !dumpFile.delete()) { - logger.error("Failed to remove file " + dumpFile.getAbsolutePath()); - } + } catch (Throwable t) { + logger.error("Failed to dump continuous recording", t); + deleteFileQuietly(dumpFile); return; } @@ -336,16 +360,10 @@ private void captureContinuousRecording( try { logger.info("Uploading continuous recording snapshot"); uploadService.upload(alertBreach, bufferStart.toEpochMilli(), dumpFile, uploadListener); - } catch (Exception e) { - logger.error("Failed to upload recording", e); - } catch (Error e) { - // rethrow errors - logger.error("Failed to upload recording", e); - throw e; + } catch (Throwable t) { + logger.error("Failed to upload recording", t); } finally { - if (dumpFile.exists() && !dumpFile.delete()) { - logger.error("Failed to remove file " + dumpFile.getAbsolutePath()); - } + deleteFileQuietly(dumpFile); } } @@ -409,8 +427,13 @@ protected Recording createRecording( } /** Perform a profile and notify the handler. */ + @SuppressWarnings( + "CatchingUnchecked") // profiler failures must never escape into the instrumented application private void executeProfile( - AlertMetricType alertType, Duration duration, Consumer handler) { + AlertMetricType alertType, + Duration duration, + Consumer handler, + Runnable diagnosticAction) { logger.info("Received " + alertType + " alert, Starting profile"); @@ -419,27 +442,48 @@ private void executeProfile( return; } - Recording newRecording = startRecording(alertType, duration); - - if (newRecording == null) { - return; - } - + Recording newRecording = null; try { + newRecording = startRecording(alertType, duration); + if (newRecording == null) { + return; + } + newRecording.start(); // schedule closing the recording + Recording startedRecording = newRecording; scheduledExecutorService.schedule( - () -> handler.accept(newRecording), duration.getSeconds(), TimeUnit.SECONDS); + () -> handler.accept(startedRecording), duration.getSeconds(), TimeUnit.SECONDS); + runDiagnosticAction(diagnosticAction); - } catch (IOException ioException) { - logger.error("Failed to start JFR recording", ioException); - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(ioException); - } catch (JfrConnectionException internalError) { - logger.error("Internal JFR Error", internalError); - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(internalError); + } catch (Throwable t) { + logger.error("Failed to start or schedule JFR recording", t); + closeRecordingAfterFailure(newRecording); + clearActiveRecordingAfterFailure(); + } + } + + @SuppressWarnings( + "CatchingUnchecked") // diagnostic failures must not abort or escape profiler capture + private static void runDiagnosticAction(Runnable diagnosticAction) { + try { + diagnosticAction.run(); + } catch (Throwable t) { + logger.error("Failed to emit profiler diagnostics", t); + } + } + + @SuppressWarnings( + "CatchingUnchecked") // profiler failures must never escape into the instrumented application + private static void closeRecordingAfterFailure(@Nullable Recording recording) { + if (recording == null) { + return; + } + try { + recording.close(); + } catch (Throwable t) { + logger.error("Failed to close JFR recording after startup failure", t); } } @@ -458,12 +502,8 @@ private Consumer uploadNewRecording( uploadService.upload( alertBreach, recordingStart.toEpochMilli(), activeRecordingFile, uploadListener); - } catch (Exception e) { - logger.error("Failed to upload recording", e); - } catch (Error e) { - // rethrow errors - logger.error("Failed to upload recording", e); - throw e; + } catch (Throwable t) { + logger.error("Failed to upload recording", t); } finally { clearActiveRecording(); } @@ -513,19 +553,40 @@ private static void writeFileFromStream(Recording recording, File recordingFile) // visible for testing void clearActiveRecording() { + clearActiveRecording(true); + } + + private void clearActiveRecording(boolean startCooldown) { synchronized (activeRecordingLock) { activeRecording = null; - // Start global cooldown now that the recording is complete - startGlobalCooldown(); - - // delete uploaded profile - if (activeRecordingFile != null && activeRecordingFile.exists()) { - if (!activeRecordingFile.delete()) { - logger.error("Failed to remove file " + activeRecordingFile.getAbsolutePath()); - } + if (startCooldown) { + // Start global cooldown now that the recording is complete + startGlobalCooldown(); } + + File recordingFile = activeRecordingFile; activeRecordingFile = null; + deleteFileQuietly(recordingFile); + } + } + + private void clearActiveRecordingAfterFailure() { + clearActiveRecording(false); + } + + @SuppressWarnings( + "CatchingUnchecked") // cleanup failures must not affect the instrumented application + private static void deleteFileQuietly(@Nullable File file) { + if (file == null) { + return; + } + try { + if (file.exists() && !file.delete()) { + logger.error("Failed to remove file " + file.getAbsolutePath()); + } + } catch (Throwable t) { + logger.error("Failed to remove file " + file.getAbsolutePath(), t); } } @@ -591,6 +652,11 @@ private void performPeriodicProfile(UploadListener uploadListener) { /** Dispatch alert breach event to handler. */ // visible for tests public void accept(AlertBreach alertBreach, UploadListener uploadListener) { + accept(alertBreach, uploadListener, () -> {}); + } + + public void accept( + AlertBreach alertBreach, UploadListener uploadListener, Runnable diagnosticAction) { if (alertBreach.getType() == AlertMetricType.PERIODIC) { performPeriodicProfile(uploadListener); @@ -598,7 +664,8 @@ public void accept(AlertBreach alertBreach, UploadListener uploadListener) { profileAndUpload( alertBreach, Duration.ofSeconds(alertBreach.getAlertConfiguration().getProfileDurationSeconds()), - uploadListener); + uploadListener, + diagnosticAction); } } } 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 a18e26e765f..be3692ad444 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 @@ -56,13 +56,7 @@ public static AlertingSubsystem create( // TODO (trask) delay creation of AlertingSubsystem until after Profiler is created and // initialized? Consumer alertAction = - alert -> - alertAction( - alert, - profiler, - diagnosticEngine, - telemetryClient, - configuration.enableContinuousProfiling); + alert -> alertAction(alert, profiler, diagnosticEngine, telemetryClient); alertingSubsystem = AlertingSubsystem.create( @@ -137,30 +131,21 @@ private static void alertAction( AlertBreach alert, Profiler profiler, DiagnosticEngine diagnosticEngine, - TelemetryClient telemetryClient, - boolean continuousProfilingEnabled) { + TelemetryClient telemetryClient) { if (profiler != null) { // This is an event that the backend specifically looks for to track when a profile is // started sendMessageTelemetry(telemetryClient, "StartProfiler triggered."); - // With continuous profiling the profiler immediately dumps a backward-looking snapshot of the - // circular buffer, so the breach diagnostics (AlertBreach, CGroupData, MachineInfo) must be - // emitted before the dump in order to be captured in the recording. - if (continuousProfilingEnabled && diagnosticEngine != null) { - diagnosticEngine.performDiagnosis(alert); - } - profiler.accept( alert, - serviceProfilerIndex -> sendServiceProfilerIndex(serviceProfilerIndex, telemetryClient)); - - // With traditional profiling a new forward-looking recording is created, so diagnostics are - // emitted after the recording has started. - if (!continuousProfilingEnabled && diagnosticEngine != null) { - diagnosticEngine.performDiagnosis(alert); - } + serviceProfilerIndex -> sendServiceProfilerIndex(serviceProfilerIndex, telemetryClient), + () -> { + if (diagnosticEngine != null) { + diagnosticEngine.performDiagnosis(alert); + } + }); } } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClient.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClient.java index 3d96b05328a..c2733935be0 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClient.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClient.java @@ -348,6 +348,14 @@ public void populateDefaults(AbstractTelemetryBuilder telemetryBuilder, Resource new ResourceParser().updateRoleNameAndInstance(telemetryBuilder, resource); } + public void populateDefaultsForHeartbeat( + AbstractTelemetryBuilder telemetryBuilder, Resource ignoredResource) { + // HeartbeatExporter supplies Resource.empty(), but targeting must use the same final resource + // identity as every other telemetry item. + Resource resource = otelResource; + populateDefaults(telemetryBuilder, resource == null ? ignoredResource : resource); + } + @Nullable public ConnectionString getConnectionString() { return connectionString; diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java index 5f3b787b9cc..b157613c97a 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java @@ -4,10 +4,13 @@ package com.microsoft.applicationinsights.agent.internal.profiler; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -28,9 +31,12 @@ import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; class ProfilerContinuousProfilingTest { @TempDir File tempDir; @@ -39,8 +45,9 @@ class ProfilerContinuousProfilingTest { private ScheduledExecutorService executor; @AfterEach + @SuppressWarnings("DirectInvocationOnMock") void tearDown() { - if (executor != null) { + if (executor != null && !mockingDetails(executor).isMock()) { executor.shutdownNow(); } } @@ -61,26 +68,35 @@ private static AlertBreach manualBreach(int profileDurationSeconds) { .build(); } + private static AlertBreach targetedBreach(int profileDurationSeconds) { + return manualBreach(profileDurationSeconds).toBuilder() + .setSettingsMoniker("Portal_test") + .setTargeted(true) + .build(); + } + @Test - void profileRequestAlwaysDumpsWholeBufferEvenForShorterRequestedDuration() throws Exception { + void targetedProfileUsesExactOneSecondOnDemandRecording() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; config.globalCooldownSeconds = 0; Recording continuousRecording = mock(Recording.class); + Recording onDemandRecording = mock(Recording.class); + AtomicInteger recordingCount = new AtomicInteger(); Profiler profiler = new Profiler(config, tempDir, timeSource) { @Override protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { - return continuousRecording; + return recordingCount.getAndIncrement() == 0 ? continuousRecording : onDemandRecording; } }; UploadService uploadService = mock(UploadService.class); FlightRecorderConnection frc = mock(FlightRecorderConnection.class); - executor = Executors.newScheduledThreadPool(1); + executor = mock(ScheduledExecutorService.class); Instant now = Instant.parse("2025-01-01T00:00:00Z"); // The continuous recording has been running for longer than maxAge, so the circular buffer is @@ -94,39 +110,42 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c timeSource.setNow(now); UploadListener noOp = index -> {}; - // A live circular buffer can only be dumped in its entirety; a shorter portal-/JMX-configured - // duration (10s) cannot be honored by streaming a sub-window from the still-running recording, - // so the whole 60s buffer is dumped via the robust dump() path. - profiler.profileAndUpload(manualBreach(10), Duration.ofSeconds(10), noOp); + profiler.profileAndUpload(targetedBreach(1), Duration.ofSeconds(1), noOp); - verify(continuousRecording).dump(anyString()); + verify(continuousRecording, never()).dump(anyString()); verify(continuousRecording, never()).getStream(any(), any()); verify(continuousRecording, never()).stop(); - // The captured window is the whole 60s buffer, so the profile is timestamped at now - 60s. - verify(uploadService) - .upload(any(), eq(now.minusSeconds(60).toEpochMilli()), any(File.class), any()); - assertThat(profiler.isRecordingActive()).isFalse(); + verify(onDemandRecording).start(); + verify(executor).schedule(any(Runnable.class), eq(1L), eq(TimeUnit.SECONDS)); + assertThat(profiler.isRecordingActive()).isTrue(); + + Runnable rejectedDiagnostic = mock(Runnable.class); + profiler.accept(manualBreach(1), noOp, rejectedDiagnostic); + verify(continuousRecording, never()).dump(anyString()); + verify(rejectedDiagnostic, never()).run(); } @Test - void profileRequestDumpsWholeBufferWhenRequestedDurationExceedsMaxAge() throws Exception { + void targetedProfileUsesExactMaximumDurationOnDemandRecording() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; config.globalCooldownSeconds = 0; Recording continuousRecording = mock(Recording.class); + Recording onDemandRecording = mock(Recording.class); + AtomicInteger recordingCount = new AtomicInteger(); Profiler profiler = new Profiler(config, tempDir, timeSource) { @Override protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { - return continuousRecording; + return recordingCount.getAndIncrement() == 0 ? continuousRecording : onDemandRecording; } }; UploadService uploadService = mock(UploadService.class); FlightRecorderConnection frc = mock(FlightRecorderConnection.class); - executor = Executors.newScheduledThreadPool(1); + executor = mock(ScheduledExecutorService.class); Instant now = Instant.parse("2025-01-01T00:00:00Z"); // The continuous recording has been running for longer than maxAge, so the buffer is full. @@ -138,26 +157,155 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c timeSource.setNow(now); UploadListener noOp = index -> {}; - // The requested duration (90s) exceeds the 60s buffer, so the whole circular buffer is dumped - // via the more robust dump() path. - profiler.profileAndUpload(manualBreach(90), Duration.ofSeconds(90), noOp); + profiler.profileAndUpload(targetedBreach(360), Duration.ofMinutes(6), noOp); - verify(continuousRecording).dump(anyString()); + verify(continuousRecording, never()).clone(true); + verify(continuousRecording, never()).dump(anyString()); verify(continuousRecording, never()).getStream(any(), any()); verify(continuousRecording, never()).stop(); - // The captured window is the whole 60s buffer, so the profile is timestamped at now - 60s. - verify(uploadService) - .upload(any(), eq(now.minusSeconds(60).toEpochMilli()), any(File.class), any()); + verify(onDemandRecording).start(); + verify(executor).schedule(any(Runnable.class), eq(360L), eq(TimeUnit.SECONDS)); + assertThat(profiler.isRecordingActive()).isTrue(); + } + + @Test + void targetedProfileUploadErrorDoesNotEscape() throws Exception { + Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); + config.enableContinuousProfiling = true; + config.continuousProfilingMaxAgeSeconds = 60; + config.globalCooldownSeconds = 0; + + Recording continuousRecording = mock(Recording.class); + Recording onDemandRecording = mock(Recording.class); + AtomicInteger recordingCount = new AtomicInteger(); + Profiler profiler = + new Profiler(config, tempDir, timeSource) { + @Override + protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { + return recordingCount.getAndIncrement() == 0 ? continuousRecording : onDemandRecording; + } + }; + + UploadService uploadService = mock(UploadService.class); + FlightRecorderConnection frc = mock(FlightRecorderConnection.class); + executor = mock(ScheduledExecutorService.class); + ArgumentCaptor scheduledUpload = ArgumentCaptor.forClass(Runnable.class); + + timeSource.setNow(Instant.parse("2025-01-01T00:00:00Z")); + profiler.initialize(uploadService, executor, frc); + profiler.profileAndUpload(targetedBreach(1), Duration.ofSeconds(1), index -> {}); + + verify(executor).schedule(scheduledUpload.capture(), eq(1L), eq(TimeUnit.SECONDS)); + doThrow(new AssertionError("simulated upload failure")) + .when(uploadService) + .upload(any(), any(Long.class), any(File.class), any()); + + assertThatCode(scheduledUpload.getValue()::run).doesNotThrowAnyException(); assertThat(profiler.isRecordingActive()).isFalse(); } @Test - void profileRequestSoonAfterStartupReportsActualCapturedWindow() throws Exception { + void targetedProfileCreationErrorDoesNotEscape() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; config.globalCooldownSeconds = 0; + Recording continuousRecording = mock(Recording.class); + AtomicInteger recordingCount = new AtomicInteger(); + Profiler profiler = + new Profiler(config, tempDir, timeSource) { + @Override + protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { + if (recordingCount.getAndIncrement() == 0) { + return continuousRecording; + } + throw new AssertionError("simulated recording creation failure"); + } + }; + + UploadService uploadService = mock(UploadService.class); + FlightRecorderConnection frc = mock(FlightRecorderConnection.class); + executor = mock(ScheduledExecutorService.class); + + timeSource.setNow(Instant.parse("2025-01-01T00:00:00Z")); + profiler.initialize(uploadService, executor, frc); + + assertThatCode( + () -> profiler.profileAndUpload(targetedBreach(1), Duration.ofSeconds(1), index -> {})) + .doesNotThrowAnyException(); + assertThat(profiler.isRecordingActive()).isFalse(); + } + + @Test + void targetedProfileSchedulingErrorClosesRecordingAndDoesNotEscape() throws Exception { + Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); + config.enableContinuousProfiling = true; + config.continuousProfilingMaxAgeSeconds = 60; + config.globalCooldownSeconds = 120; + + Recording continuousRecording = mock(Recording.class); + Recording onDemandRecording = mock(Recording.class); + AtomicInteger recordingCount = new AtomicInteger(); + Profiler profiler = + new Profiler(config, tempDir, timeSource) { + @Override + protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { + return recordingCount.getAndIncrement() == 0 ? continuousRecording : onDemandRecording; + } + }; + + UploadService uploadService = mock(UploadService.class); + FlightRecorderConnection frc = mock(FlightRecorderConnection.class); + executor = mock(ScheduledExecutorService.class); + doThrow(new AssertionError("simulated scheduling failure")) + .when(executor) + .schedule(any(Runnable.class), eq(1L), eq(TimeUnit.SECONDS)); + + timeSource.setNow(Instant.parse("2025-01-01T00:00:00Z")); + profiler.initialize(uploadService, executor, frc); + + assertThatCode( + () -> profiler.profileAndUpload(targetedBreach(1), Duration.ofSeconds(1), index -> {})) + .doesNotThrowAnyException(); + verify(onDemandRecording).close(); + assertThat(profiler.isRecordingActive()).isFalse(); + assertThat(profiler.getGlobalCooldownUntil()).isEqualTo(Instant.MIN); + } + + @Test + void continuousRecordingStartupErrorClosesRecordingAndDoesNotEscape() throws Exception { + Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); + config.enableContinuousProfiling = true; + config.continuousProfilingMaxAgeSeconds = 60; + + Recording continuousRecording = mock(Recording.class); + doThrow(new AssertionError("simulated startup failure")).when(continuousRecording).start(); + Profiler profiler = + new Profiler(config, tempDir, timeSource) { + @Override + protected Recording createRecording(RecordingOptions o, RecordingConfiguration c) { + return continuousRecording; + } + }; + + assertThatCode( + () -> + profiler.initialize( + mock(UploadService.class), + mock(ScheduledExecutorService.class), + mock(FlightRecorderConnection.class))) + .doesNotThrowAnyException(); + verify(continuousRecording).close(); + assertThat(profiler.isContinuousRecordingRunning()).isFalse(); + } + + @Test + void profileRequestSoonAfterStartupReportsActualCapturedWindow() throws Exception { + Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); + config.enableContinuousProfiling = true; + config.continuousProfilingMaxAgeSeconds = 60; + config.globalCooldownSeconds = 0; Recording continuousRecording = mock(Recording.class); Profiler profiler = new Profiler(config, tempDir, timeSource) { diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClientTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClientTest.java new file mode 100644 index 00000000000..e269cb48fb7 --- /dev/null +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/telemetry/TelemetryClientTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.applicationinsights.agent.internal.telemetry; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.azure.monitor.opentelemetry.autoconfigure.implementation.builders.MetricTelemetryBuilder; +import com.azure.monitor.opentelemetry.autoconfigure.implementation.models.ContextTagKeys; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import org.junit.jupiter.api.Test; + +class TelemetryClientTest { + + @Test + void heartbeatUsesSameResourceDerivedIdentityAsOtherTelemetry() { + TelemetryClient telemetryClient = TelemetryClient.createForTest(); + telemetryClient.setOtelResource( + Resource.create( + Attributes.builder() + .put("service.namespace", "production") + .put("service.name", "orders") + .put("service.instance.id", "pod-1") + .build())); + + MetricTelemetryBuilder heartbeatBuilder = MetricTelemetryBuilder.create("HeartbeatState", 1); + telemetryClient.populateDefaultsForHeartbeat(heartbeatBuilder, Resource.empty()); + + assertThat(heartbeatBuilder.build().getTags()) + .containsEntry(ContextTagKeys.AI_CLOUD_ROLE.toString(), "[production]/orders") + .containsEntry(ContextTagKeys.AI_CLOUD_ROLE_INSTANCE.toString(), "pod-1") + .containsAllEntriesOf(telemetryClient.newMessageTelemetryBuilder().build().getTags()); + } +} diff --git a/docs/README.md b/docs/README.md index 56615a8d9af..2dc8963eb0b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -146,10 +146,10 @@ captured window. Note the following limitations while this feature is in preview - The continuous recording uses the `cpuTriggeredSettings` JFC for all trigger types, so `memoryTriggeredSettings` and `manualTriggeredSettings` are not applied to continuous captures. -- A requested profile duration (from the portal, JMX, or a file trigger) is ignored: each request - dumps the whole retained circular buffer (up to `continuousProfilingMaxAgeSeconds`), because a - live JFR recording can only be dumped in its entirety and cannot be streamed for a sub-window - without being stopped. +- A targeted portal request uses a separate on-demand recording so that its requested duration is + honored. Other requests, including legacy Profile Now, JMX, and file triggers, dump the whole + retained circular buffer (up to `continuousProfilingMaxAgeSeconds`), because a live JFR recording + can only be dumped in its entirety and cannot be streamed for a sub-window without being stopped. - Because JFR runs for the lifetime of the JVM rather than in short bursts, expect a steady-state increase in CPU, memory and disk I/O compared to on-demand profiling. From 2e9892b2f7ca0e69cc1f02e8026260f13615eb16 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:25:48 +0000 Subject: [PATCH 4/7] Preserve heartbeat startup timing Keep heartbeat initialization in SecondEntryPoint while resolving the finalized OpenTelemetry resource lazily for each emission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/init/AfterAgentListener.java | 1 - .../agent/internal/init/SecondEntryPoint.java | 24 +++++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java index 9fbda8fb4e9..b27d77d3e67 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java @@ -33,7 +33,6 @@ public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetr PerformanceCounterInitializer.initialize(configuration); TelemetryClient telemetryClient = TelemetryClient.getActive(); - SecondEntryPoint.startHeartbeat(configuration, telemetryClient); if (configuration.preview.browserSdkLoader.enabled && telemetryClient != null && telemetryClient.getConnectionString() != null) { diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java index 07652b70dbc..58ac2a0ab5f 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/SecondEntryPoint.java @@ -148,6 +148,17 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) { Consumer> heartbeatTelemetryItemConsumer = createHeartbeatTelemetryItemConsumer(telemetryClient); + if (telemetryClient.getConnectionString() != null) { + startupLogger.verbose("connection string is not null, start HeartbeatExporter"); + // interval longer than 15 minutes is not allowed since we use this data for usage telemetry + long intervalSeconds = + Math.min(configuration.heartbeat.intervalSeconds, MINUTES.toSeconds(15)); + HeartbeatExporter.start( + intervalSeconds, + telemetryClient::populateDefaultsForHeartbeat, + heartbeatTelemetryItemConsumer); + } + TelemetryClient.setActive(telemetryClient); // TODO (heya) remove duplicate code in both RuntimeConfigurator and SecondEntryPoint @@ -305,19 +316,6 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) { }); } - static void startHeartbeat(Configuration configuration, TelemetryClient telemetryClient) { - if (telemetryClient.getConnectionString() == null) { - return; - } - startupLogger.verbose("connection string is not null, start HeartbeatExporter"); - // interval longer than 15 minutes is not allowed since we use this data for usage telemetry - long intervalSeconds = Math.min(configuration.heartbeat.intervalSeconds, MINUTES.toSeconds(15)); - HeartbeatExporter.start( - intervalSeconds, - telemetryClient::populateDefaultsForHeartbeat, - createHeartbeatTelemetryItemConsumer(telemetryClient)); - } - private static Consumer> createHeartbeatTelemetryItemConsumer( TelemetryClient telemetryClient) { return telemetryItems -> { From 5d8f907ea1dc96b796f88a8994dad3d81af4963a Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:47:05 +0000 Subject: [PATCH 5/7] Narrow profiler exception handling Avoid swallowing JVM errors while retaining profiler cleanup and failure containment for exceptions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/init/AfterAgentListener.java | 7 +--- .../internal/init/RuntimeConfigurator.java | 7 +--- .../agent/internal/profiler/Profiler.java | 40 +++++++++---------- .../ProfilerContinuousProfilingTest.java | 16 ++++---- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java index b27d77d3e67..7318ff91165 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/AfterAgentListener.java @@ -20,9 +20,6 @@ public class AfterAgentListener implements AgentListener { private static final Logger logger = LoggerFactory.getLogger(AfterAgentListener.class); @Override - @SuppressWarnings( - "CatchingUnchecked") // optional profiler failures must not affect the instrumented - // application public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) { // only safe now to make HTTPS calls because Java SSL classes // trigger loading of java.util.logging (starting with Java 8u231) @@ -49,8 +46,8 @@ public void afterAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetr configuration.role.name, configuration.role.instance, TelemetryClient.getActive()); - } catch (Throwable t) { - logger.warn("Failed to initialize profiler", t); + } catch (RuntimeException e) { + logger.warn("Failed to initialize profiler", e); } } } diff --git a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java index 96c2bb196f4..b8201fa8de3 100644 --- a/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java +++ b/agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/init/RuntimeConfigurator.java @@ -110,9 +110,6 @@ public RuntimeConfiguration getCurrentConfigCopy() { return copy(currentConfig); } - @SuppressWarnings( - "CatchingUnchecked") // optional profiler failures must not affect the instrumented - // application public void apply(RuntimeConfiguration runtimeConfig) { logger.debug("Applying runtime configuration"); @@ -154,9 +151,9 @@ public void apply(RuntimeConfiguration runtimeConfig) { runtimeConfig.role.name, runtimeConfig.role.instance, telemetryClient); - } catch (Throwable t) { + } catch (RuntimeException e) { profilerStarted.set(false); - logger.warn("Failed to initialize profiler", t); + logger.warn("Failed to initialize profiler", e); } } else { logger.debug("Profiler has already been initialized."); 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 46a3ad3c9de..67728564bba 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 @@ -261,8 +261,8 @@ private void startContinuousRecordingIfEnabled() { + " of {} bytes", continuousProfilingMaxAge.getSeconds(), CONTINUOUS_PROFILING_MAX_SIZE_BYTES); - } catch (Throwable t) { - logger.error("Failed to start continuous JFR recording", t); + } catch (Exception e) { + logger.error("Failed to start continuous JFR recording", e); closeRecordingAfterFailure(newRecording); continuousRecording = null; continuousRecordingStart = null; @@ -337,8 +337,8 @@ private void captureContinuousRecording( try { dumpFile = createJfrFile(bufferStart, recordingEnd); - } catch (Throwable t) { - logger.error("Failed to create jfr file", t); + } catch (Exception e) { + logger.error("Failed to create jfr file", e); return; } @@ -346,8 +346,8 @@ private void captureContinuousRecording( // Dump the current state of the circular buffer, capturing up to maxAge of data. The // continuous recording keeps running so future requests can be serviced immediately. continuousRecording.dump(dumpFile.getAbsolutePath()); - } catch (Throwable t) { - logger.error("Failed to dump continuous recording", t); + } catch (Exception e) { + logger.error("Failed to dump continuous recording", e); deleteFileQuietly(dumpFile); return; } @@ -360,8 +360,8 @@ private void captureContinuousRecording( try { logger.info("Uploading continuous recording snapshot"); uploadService.upload(alertBreach, bufferStart.toEpochMilli(), dumpFile, uploadListener); - } catch (Throwable t) { - logger.error("Failed to upload recording", t); + } catch (Exception e) { + logger.error("Failed to upload recording", e); } finally { deleteFileQuietly(dumpFile); } @@ -457,8 +457,8 @@ private void executeProfile( () -> handler.accept(startedRecording), duration.getSeconds(), TimeUnit.SECONDS); runDiagnosticAction(diagnosticAction); - } catch (Throwable t) { - logger.error("Failed to start or schedule JFR recording", t); + } catch (Exception e) { + logger.error("Failed to start or schedule JFR recording", e); closeRecordingAfterFailure(newRecording); clearActiveRecordingAfterFailure(); } @@ -469,21 +469,19 @@ private void executeProfile( private static void runDiagnosticAction(Runnable diagnosticAction) { try { diagnosticAction.run(); - } catch (Throwable t) { - logger.error("Failed to emit profiler diagnostics", t); + } catch (Exception e) { + logger.error("Failed to emit profiler diagnostics", e); } } - @SuppressWarnings( - "CatchingUnchecked") // profiler failures must never escape into the instrumented application private static void closeRecordingAfterFailure(@Nullable Recording recording) { if (recording == null) { return; } try { recording.close(); - } catch (Throwable t) { - logger.error("Failed to close JFR recording after startup failure", t); + } catch (IOException | JfrConnectionException e) { + logger.error("Failed to close JFR recording after startup failure", e); } } @@ -502,8 +500,8 @@ private Consumer uploadNewRecording( uploadService.upload( alertBreach, recordingStart.toEpochMilli(), activeRecordingFile, uploadListener); - } catch (Throwable t) { - logger.error("Failed to upload recording", t); + } catch (Exception e) { + logger.error("Failed to upload recording", e); } finally { clearActiveRecording(); } @@ -575,8 +573,6 @@ private void clearActiveRecordingAfterFailure() { clearActiveRecording(false); } - @SuppressWarnings( - "CatchingUnchecked") // cleanup failures must not affect the instrumented application private static void deleteFileQuietly(@Nullable File file) { if (file == null) { return; @@ -585,8 +581,8 @@ private static void deleteFileQuietly(@Nullable File file) { if (file.exists() && !file.delete()) { logger.error("Failed to remove file " + file.getAbsolutePath()); } - } catch (Throwable t) { - logger.error("Failed to remove file " + file.getAbsolutePath(), t); + } catch (RuntimeException e) { + logger.error("Failed to remove file " + file.getAbsolutePath(), e); } } diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java index b157613c97a..d1eb85f640b 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java @@ -169,7 +169,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c } @Test - void targetedProfileUploadErrorDoesNotEscape() throws Exception { + void targetedProfileUploadFailureDoesNotEscape() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; @@ -196,7 +196,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c profiler.profileAndUpload(targetedBreach(1), Duration.ofSeconds(1), index -> {}); verify(executor).schedule(scheduledUpload.capture(), eq(1L), eq(TimeUnit.SECONDS)); - doThrow(new AssertionError("simulated upload failure")) + doThrow(new IllegalStateException("simulated upload failure")) .when(uploadService) .upload(any(), any(Long.class), any(File.class), any()); @@ -205,7 +205,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c } @Test - void targetedProfileCreationErrorDoesNotEscape() throws Exception { + void targetedProfileCreationFailureDoesNotEscape() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; @@ -220,7 +220,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c if (recordingCount.getAndIncrement() == 0) { return continuousRecording; } - throw new AssertionError("simulated recording creation failure"); + throw new IllegalStateException("simulated recording creation failure"); } }; @@ -238,7 +238,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c } @Test - void targetedProfileSchedulingErrorClosesRecordingAndDoesNotEscape() throws Exception { + void targetedProfileSchedulingFailureClosesRecordingAndDoesNotEscape() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; @@ -258,7 +258,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c UploadService uploadService = mock(UploadService.class); FlightRecorderConnection frc = mock(FlightRecorderConnection.class); executor = mock(ScheduledExecutorService.class); - doThrow(new AssertionError("simulated scheduling failure")) + doThrow(new IllegalStateException("simulated scheduling failure")) .when(executor) .schedule(any(Runnable.class), eq(1L), eq(TimeUnit.SECONDS)); @@ -274,13 +274,13 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c } @Test - void continuousRecordingStartupErrorClosesRecordingAndDoesNotEscape() throws Exception { + void continuousRecordingStartupFailureClosesRecordingAndDoesNotEscape() throws Exception { Configuration.ProfilerConfiguration config = new Configuration.ProfilerConfiguration(); config.enableContinuousProfiling = true; config.continuousProfilingMaxAgeSeconds = 60; Recording continuousRecording = mock(Recording.class); - doThrow(new AssertionError("simulated startup failure")).when(continuousRecording).start(); + doThrow(new IllegalStateException("simulated startup failure")).when(continuousRecording).start(); Profiler profiler = new Profiler(config, tempDir, timeSource) { @Override From 89b59db0f499e005fc9a6b7feea70f6312ce8069 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:47:51 +0000 Subject: [PATCH 6/7] Spotless --- .../internal/profiler/ProfilerContinuousProfilingTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java index d1eb85f640b..94018dd31b1 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java @@ -280,7 +280,9 @@ void continuousRecordingStartupFailureClosesRecordingAndDoesNotEscape() throws E config.continuousProfilingMaxAgeSeconds = 60; Recording continuousRecording = mock(Recording.class); - doThrow(new IllegalStateException("simulated startup failure")).when(continuousRecording).start(); + doThrow(new IllegalStateException("simulated startup failure")) + .when(continuousRecording) + .start(); Profiler profiler = new Profiler(config, tempDir, timeSource) { @Override From 3cf6c4cf390d973113e3c26e22c975c423dbbd52 Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:21:08 +0000 Subject: [PATCH 7/7] Use specific profiler exceptions Catch only expected JFR, recording-state, scheduling, and diagnostic failures introduced by targeted profiling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent/internal/profiler/Profiler.java | 20 +++++++++---------- .../ProfilerContinuousProfilingTest.java | 3 ++- 2 files changed, 11 insertions(+), 12 deletions(-) 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 67728564bba..5ad882bdc2e 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 @@ -25,6 +25,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -230,8 +231,6 @@ private boolean usesContinuousRecordingSnapshot(AlertBreach alertBreach) { return continuousProfilingEnabled && !alertBreach.isTargeted(); } - @SuppressWarnings( - "CatchingUnchecked") // profiler failures must never escape into the instrumented application private void startContinuousRecordingIfEnabled() { if (!continuousProfilingEnabled) { return; @@ -261,7 +260,7 @@ private void startContinuousRecordingIfEnabled() { + " of {} bytes", continuousProfilingMaxAge.getSeconds(), CONTINUOUS_PROFILING_MAX_SIZE_BYTES); - } catch (Exception e) { + } catch (IOException | JfrConnectionException | IllegalStateException e) { logger.error("Failed to start continuous JFR recording", e); closeRecordingAfterFailure(newRecording); continuousRecording = null; @@ -337,7 +336,7 @@ private void captureContinuousRecording( try { dumpFile = createJfrFile(bufferStart, recordingEnd); - } catch (Exception e) { + } catch (IOException e) { logger.error("Failed to create jfr file", e); return; } @@ -346,7 +345,7 @@ private void captureContinuousRecording( // Dump the current state of the circular buffer, capturing up to maxAge of data. The // continuous recording keeps running so future requests can be serviced immediately. continuousRecording.dump(dumpFile.getAbsolutePath()); - } catch (Exception e) { + } catch (IOException | JfrConnectionException | IllegalStateException e) { logger.error("Failed to dump continuous recording", e); deleteFileQuietly(dumpFile); return; @@ -427,8 +426,6 @@ protected Recording createRecording( } /** Perform a profile and notify the handler. */ - @SuppressWarnings( - "CatchingUnchecked") // profiler failures must never escape into the instrumented application private void executeProfile( AlertMetricType alertType, Duration duration, @@ -457,19 +454,20 @@ private void executeProfile( () -> handler.accept(startedRecording), duration.getSeconds(), TimeUnit.SECONDS); runDiagnosticAction(diagnosticAction); - } catch (Exception e) { + } catch (IOException + | JfrConnectionException + | IllegalStateException + | RejectedExecutionException e) { logger.error("Failed to start or schedule JFR recording", e); closeRecordingAfterFailure(newRecording); clearActiveRecordingAfterFailure(); } } - @SuppressWarnings( - "CatchingUnchecked") // diagnostic failures must not abort or escape profiler capture private static void runDiagnosticAction(Runnable diagnosticAction) { try { diagnosticAction.run(); - } catch (Exception e) { + } catch (RuntimeException e) { logger.error("Failed to emit profiler diagnostics", e); } } diff --git a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java index 94018dd31b1..542a0a7f338 100644 --- a/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java +++ b/agent/agent-tooling/src/test/java/com/microsoft/applicationinsights/agent/internal/profiler/ProfilerContinuousProfilingTest.java @@ -30,6 +30,7 @@ import java.time.Instant; import java.util.UUID; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -258,7 +259,7 @@ protected Recording createRecording(RecordingOptions o, RecordingConfiguration c UploadService uploadService = mock(UploadService.class); FlightRecorderConnection frc = mock(FlightRecorderConnection.class); executor = mock(ScheduledExecutorService.class); - doThrow(new IllegalStateException("simulated scheduling failure")) + doThrow(new RejectedExecutionException("simulated scheduling failure")) .when(executor) .schedule(any(Runnable.class), eq(1L), eq(TimeUnit.SECONDS));