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/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..b0778f2d0dd 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,50 @@ 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()); + } + } + + 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()); + } + } + + private void dispatchManualAlert(int durationSeconds) { + AlertBreach alertBreach = + AlertBreach.builder() + .setType(AlertMetricType.MANUAL) + .setAlertValue(0.0) + .setAlertConfiguration( + AlertConfiguration.builder() + .setType(AlertMetricType.MANUAL) + .setEnabled(true) + .setProfileDurationSeconds(durationSeconds) + .build()) + .setProfileId(UUID.randomUUID().toString()) + .setCpuMetric(0) + .setMemoryUsage(0) + .build(); + alertHandler.accept(alertBreach); } /** 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..d4a8e6812a1 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; @@ -165,4 +168,109 @@ 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(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..ccafb814e7b 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,7 +35,7 @@ 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"; public static final String API_FEATURE_VERSION = "2020-10-14-preview"; private final URL hostUrl; @@ -153,6 +153,10 @@ public Mono getSettings(Date oldTimeStamp) { } 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 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/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/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/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/docs/adr/targettedProfiling/targettedProfiling.md b/docs/adr/targettedProfiling/targettedProfiling.md new file mode 100644 index 00000000000..0934a93c828 --- /dev/null +++ b/docs/adr/targettedProfiling/targettedProfiling.md @@ -0,0 +1,594 @@ +# ADR: Java Agent Support for Targeted Profile Now + +- **Status:** Proposed +- **Repository:** `microsoft/ApplicationInsights-Java` +- **Service contract:** + `ServiceProfiler/documentation/ADR/targeted_profile_now/profilenow_adr_plan.md` +- **Portal contract:** + `MGMT-AppInsights-InsightsPortal/docs/adr/targeted_profile_now/profilenow_adr_portal_plan.md` +- **Settings protocol version:** `2.0.0` + +## 1. Context + +Application Insights Profiler currently receives remote settings by polling the ServiceProfiler v4 +settings endpoint. An on-demand request is represented by the legacy `collectionPlan` command-line +string. Every agent attached to the Application Insights resource receives the same document and can +act on that request. + +ServiceProfiler is adding an optional structured `targetedCollectionPlan` to the existing v4 +settings document. The portal will use this object to target compatible Java agents by cloud role or +by role-qualified instance. Existing .NET profilers and older Java agents must remain unaffected: a +targeted write leaves the legacy `collectionPlan` empty, and clients that do not understand the new +object do nothing. + +This repository owns the Java consumer described as out of scope by the ServiceProfiler ADR. The +Java agent must deserialize the structured plan, determine whether its local role and instance are +selected, and route a valid selected plan through the existing manual JFR profiling and upload +pipeline. + +ServiceProfiler accepts targeted writes only through the DataPlane +`/api/apps/{appid}/targetedCollectionplan` endpoint. It does not add a targeted route to the +deprecated Web Stamp gateway. Concurrent targeted and broadcast writes retain the settings +document's existing last-write-wins behavior. + +## 2. Goals + +- Support targeted profiling of one or more Java role instances. +- Support targeted profiling of every Java agent instance in one or more cloud roles. +- Preserve the current explicit broadcast behavior based on the legacy `collectionPlan`. +- Reuse the existing settings poll, manual profile trigger, JFR recording, upload, active-recording + guard, and global cooldown. +- Enforce target matching, expiration, duration, and exactly-once behavior in the Java agent. +- Provide sufficient diagnostics to explain whether a targeted request was accepted, ignored, + rejected, blocked, recorded, or uploaded. +- Keep the change additive and compatible with settings documents from before and after the service + rollout. + +## 3. Existing Java architecture + +```mermaid +sequenceDiagram + participant Poller as ProfilingInitializer + participant Config as ConfigService + participant Client as ServiceProfilerClient + participant Service as ServiceProfiler v4 + participant Parser as ProfilerConfiguration / AlertConfigParser + participant Alerts as AlertingSubsystem + participant JFR as Profiler + participant Upload as UploadService + + loop Every configPollPeriodSeconds + Poller ->> Config: pullSettings() + Config ->> Client: getSettings(lastModified) + Client ->> Service: GET settings?iKey&oldTimestamp&featureVersion=1.0.0 + Service -->> Client: settings document + Client -->> Config: ProfilerConfiguration + Config -->> Poller: changed configuration only + end + + Poller ->> Parser: applyConfiguration(settings) + Parser ->> Alerts: updateConfiguration(alertingConfig) + Alerts ->> Alerts: validate expiration and deduplicate moniker + Alerts ->> JFR: MANUAL AlertBreach + JFR ->> JFR: active recording and global cooldown checks + JFR ->> Upload: upload JFR artifact +``` + +Relevant behavior already in the repository: + +- `ServiceProfilerClient` polls `api/profileragent/v4/settings` with `oldTimestamp` and feature + version `1.0.0`. +- `ConfigService` emits only settings with a changed `lastModified` value. +- ServiceProfiler can return `304 Not Modified`, but the current `ServiceProfilerClient.handle` + treats every status greater than or equal to 300 as an error. Targeted profiling must correct this + so unchanged polls complete without a configuration or error. +- `ProfilerConfiguration` manually deserializes the settings JSON and skips unknown fields. +- `AlertConfigParser` converts the legacy collection-plan string into `CollectionPlanConfiguration`, + including the .NET `DateTime.ToBinary()` expiration. +- `AlertingSubsystem` accepts unexpired immediate single plans and deduplicates them by + `settingsMoniker` for the lifetime of the process. +- `Profiler` rejects overlapping recordings, applies the configured global cooldown, records JFR, + and delegates upload. +- `UploadService` already carries the resolved role name and role instance/machine name in profile + metadata. +- `ProfilingInitializer` receives `Configuration.role.name` and `Configuration.role.instance`; these + are the same resolved identities used by telemetry and therefore are the authoritative local + values for matching. + +## 4. Decision + +### 4.1 Consume the additive v4 contract + +Change the profiler settings feature version sent by `ServiceProfilerClient` from `1.0.0` to +`2.0.0`. ServiceProfiler maps both values to the existing v4 settings container; this advertises +that the Java client understands `targetedCollectionPlan` and does not introduce a new endpoint or +storage version. + +Continue accepting old settings documents that omit `targetedCollectionPlan`. Unknown future fields +must continue to be ignored. + +Model the structured contract with immutable-by-convention Java types owned by the profiler +configuration package: + +```jsonc +"targetedCollectionPlan": { + "roles": ["frontend"], + "immediateProfilingDuration": 120, + "expiration": "2026-07-24T14:03:12.4470000Z", + "settingsMoniker": "Portal_9c2e5b31" +} +``` + +or: + +```jsonc +"targetedCollectionPlan": { + "instances": [ + { "role": "frontend", "name": "vm-1" } + ], + "immediateProfilingDuration": 120, + "expiration": "2026-07-24T14:03:12.4470000Z", + "settingsMoniker": "Portal_9c2e5b31" +} +``` + +Exactly one of `roles` or `instances` is populated. The presence of the object means "profile +immediately, once"; `single` and `mode` are intentionally not serialized. ServiceProfiler trims, +case-insensitively deduplicates, and deterministically orders the selected dimension before +persistence. + +### 4.2 Match against resolved Java resource identity + +Target matching is local and uses the role and instance passed into `ProfilingInitializer`: + +| Plan dimension | Selected when | +|----------------|--------------------------------------------------------------------------------------------------| +| `roles` | The local cloud role equals one listed role, case-insensitively. | +| `instances` | One entry's role and name both equal the local cloud role and role instance, case-insensitively. | + +Matching rules: + +- Trim values before comparison and use `Locale.ROOT` case normalization. +- Instance identity is always the pair `(role, name)`; never match a bare instance name. +- An absent plan, null list, empty list, blank identity, null entry, both dimensions, or neither + dimension selects nothing. +- If local role identity is absent or blank, no targeted plan can match. +- If local instance identity is absent or blank, role targeting may still match, but instance + targeting cannot. +- Do not reinterpret an invalid targeted plan as legacy broadcast. +- Log only bounded decision metadata. Do not log the full target list or settings payload. + +The portal normalizes instance names to lowercase and deduplicates case-insensitively. +Case-insensitive agent matching preserves compatibility without requiring the Java agent to mutate +its configured identity. + +### 4.3 Preserve targeted configuration through the alerting boundary + +`AlertConfigParser.toAlertingConfig` parses the two on-demand representations independently: + +1. The legacy `collectionPlan` continues to map to `CollectionPlanConfiguration`. +2. The structured object maps faithfully to a separate `TargetedCollectionPlanConfiguration` on + `AlertingConfiguration`, without matching it or replacing the legacy plan with a synthetic + disabled plan. + +When a targeted plan is present, it takes precedence over the legacy collection plan. The parser +retains both representations, but enablement and dispatch evaluate only the targeted plan. + +The targeted configuration preserves: + +- expiration parsed from the ISO-8601 string into an `Instant`; +- duration in seconds; +- `settingsMoniker` for deduplication and correlation. + +Defensive client validation accepts duration values from 1 through 360 seconds. Values outside that +range do not trigger profiling; they are not clamped. Expired plans do not trigger. A targeted plan +cannot enable CPU, memory, request, periodic, file, or JMX triggers; those remain independently +configured. + +### 4.4 Resolve targeting in the alerting subsystem + +`TargetedCollectionPlanConfiguration` owns validation and role/instance selection semantics. +`ProfilingInitializer` uses those semantics when deciding whether a targeted request should enable +the profiler. `AlertingSubsystem` receives the same local identity and evaluates targeted and legacy +manual plans separately, sharing moniker deduplication and `MANUAL` breach creation. + +`AlertPipelines` remains responsible only for telemetry analysis pipelines. Targeted selection does +not belong there because it is configuration-driven, does not consume telemetry data points, and +requires process role/instance identity. + +The resulting request continues through `Profiler`, so it is subject to: + +- one active recording per process; +- global cooldown across all trigger sources; +- the manual-trigger JFR configuration; +- existing upload retry/error handling; +- role and machine metadata on the uploaded artifact. + +A request rejected because a recording or cooldown is active remains consumed under current +behavior. It is not retried automatically on the next unchanged settings poll. This avoids delayed +execution after the portal request's intended immediate window, but must be surfaced through +diagnostics. + +### 4.5 Bound exactly-once state + +The current `manualTriggersExecuted` set grows for the lifetime of the process. Targeted requests +should replace this with a bounded moniker tracker shared by legacy and targeted on-demand plans. + +The tracker should: + +- reject an empty or blank moniker; +- mark a moniker before dispatch to prevent duplicate concurrent execution; +- retain entries only through the maximum useful replay window; +- enforce a fixed upper bound as defense in depth; +- use the injected `TimeSource` for deterministic tests. + +Because service-generated targeted requests expire after five minutes, retaining monikers for at +least the expiration window is sufficient to prevent a settings replay from executing twice. The +precise retention and capacity constants should be named and covered by tests. + +Process restart resets this in-memory state. Expiration remains the cross-restart safety boundary; +no disk persistence is proposed. + +### 4.6 Preserve and verify result correlation + +The existing Java code uses `settingsMoniker` only for in-process deduplication; it is not +propagated into `AlertBreach`, `ServiceProfilerIndex`, or upload metadata. The service and portal +ADRs require moniker-based progress/result correlation. + +Before implementation, the ServiceProfiler owner and Java owner must confirm the expected artifact +or telemetry field for this moniker. The implementation must then carry the moniker from +`CollectionPlanConfiguration` through the manual `AlertBreach` and upload/index path without +changing non-manual trigger metadata. This is a release-blocking contract decision, not an optional +telemetry enhancement. + +## 5. Proposed code changes + +### Phase 1: Wire contract and protocol version + +- **Modify** + `agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/service/ServiceProfilerClient.java` + - Send `featureVersion=2.0.0`. + - Keep the v4 route, `iKey`, and `oldTimestamp` behavior unchanged. + - Treat `304 Not Modified` as an empty result and continue treating other non-success responses as + failures. +- **Create** typed targeted-plan and target-instance models under + `agent/agent-tooling/src/main/java/com/microsoft/applicationinsights/agent/internal/profiler/config/`. +- **Modify** `ProfilerConfiguration.java` + - Add nullable `targetedCollectionPlan` accessors. + - Parse and serialize the nested object with `azure-json`. + - Continue skipping unknown fields at every object level. + - Treat absent or explicit null as no targeted plan. + +Acceptance criteria: + +- Old settings documents deserialize unchanged. +- Roles and instances variants round-trip with exact camelCase field names. +- The wire model contains only the selected dimension, `immediateProfilingDuration`, ISO-8601 + `expiration`, and `settingsMoniker`. +- Unknown nested fields are ignored. +- Malformed JSON fails the settings pull without partially applying a plan. +- Settings requests advertise `2.0.0`. +- An unchanged settings poll returns no configuration and does not log an error. + +### Phase 2: Identity matching and validation + +- **Create** `TargetedCollectionPlanConfiguration` in the alerting API with validation and matching + semantics. +- **Modify** `ProfilingInitializer.java` to use the typed plan when deciding whether to enable the + profiler. Do not add identity to the settings URL. +- **Modify** `AlertConfigParser.java` to parse the targeted plan independently of the legacy plan. +- **Modify** `AlertingSubsystem.java` to receive local identity and evaluate targeted dispatch. + +Acceptance criteria: + +- Role matching is case-insensitive. +- Instance matching requires both role and name. +- Duplicate instance names in different roles remain distinct. +- Empty, mixed, null, malformed, invalid-expiration, blank-moniker, invalid-duration, and unmatched + plans trigger nothing. +- Missing instance identity still permits role matching but not instance matching. +- A document containing both legacy and targeted plans evaluates only the targeted plan. + +### Phase 3: Scheduling, deduplication, and correlation + +- **Modify** + `agent/agent-profiler/agent-alerting/src/main/java/com/microsoft/applicationinsights/alerting/AlertingSubsystem.java` + - Reuse final collection-plan checks. + - Replace the unbounded executed-moniker set with a bounded tracker. + - Emit a decision outcome for selected, expired, duplicate, or invalid plans. +- **Modify** alert and upload contracts only as required by the confirmed ServiceProfiler + moniker-correlation contract: + - `agent/agent-profiler/agent-alerting-api/.../alert/AlertBreach.java` + - `agent/agent-tooling/.../profiler/upload/ServiceProfilerIndex.java` + - `agent/agent-tooling/.../profiler/upload/UploadService.java` + +Acceptance criteria: + +- A selected moniker is dispatched no more than once per process within the retention window. +- Expiration prevents replay after process restart. +- Targeted recording uses the requested duration and the manual JFR configuration. +- Active-recording and global-cooldown behavior remains unchanged. +- The confirmed moniker field is present on successful targeted output and absent where + inappropriate. + +### Phase 4: Diagnostics and supportability + +Add low-cardinality diagnostics for: + +- settings protocol version in use; +- targeted plan received; +- selected by role or instance; +- not selected; +- invalid contract; +- expired; +- duplicate moniker; +- blocked by active recording; +- blocked by global cooldown; +- recording started; +- upload succeeded or failed. + +Diagnostics must not include target arrays, instrumentation keys, connection strings, upload tokens, +or unbounded user-controlled values. The existing `"StartProfiler triggered."` telemetry event +should remain for backend compatibility unless the service owner approves a replacement. + +### Phase 5: Documentation and release metadata + +- Add an entry to `CHANGELOG.md` when implementation ships. +- Document the first Java agent version that supports targeting. +- Provide the accepted heartbeat `sdkVersion` prefix set and minimum semantic version to the portal + owner. +- Do not add a user-facing `applicationinsights.json` switch; availability is controlled by agent + version, service deployment, and the portal feature flag. + +## 6. Testing strategy + +### Unit tests: contract and protocol + +Extend `ProfilerConfigurationTest` and add focused model tests for: + +- roles and instances JSON shapes; +- null and absent targeted plan; +- exact camelCase names; +- unknown fields; +- malformed and incomplete nested objects; +- ISO-8601 expiration parsing, including the service's seven-digit fractional seconds; +- settings feature version `2.0.0` in the generated request URL. +- HTTP 200 parsing, HTTP 304 empty completion, and non-304 error responses. + +Avoid adding live service dependencies. Prefer the existing HTTP playback or a local mocked pipeline +for request inspection. + +### Unit tests: matching and mapping + +Extend `AlertConfigParserTest`, `AlertingSubsystemTest`, or add a dedicated matcher test class for: + +- role and role-instance matches; +- case and whitespace normalization; +- repeated instance names across different roles; +- unmatched targets; +- missing local identities; +- roles XOR instances invariant; +- null entries and blank values; +- valid duration boundaries 1 and 360; +- invalid duration values 0 and 361; +- expired, malformed-expiration, and blank-moniker plans; +- targeted-plan precedence when legacy and targeted plans coexist. + +### Unit tests: scheduling and safety + +Extend `AlertingSubsystemTest`, `ProfilingInitializerTest`, and `ProfilerGlobalCooldownTest` for: + +- one dispatch for a new targeted moniker; +- no dispatch for the same moniker on repeated settings updates; +- bounded moniker retention and eviction; +- selected plans enabling the profiler when no other remote trigger is enabled; +- unmatched plans not enabling the profiler solely because they exist; +- active recording and global cooldown blocking targeted requests without changing current trigger + semantics; +- targeted duration reaching the JFR recording scheduler; +- expiration evaluated through the injected time source. + +### Upload and correlation tests + +Extend `UploadServiceTest` and `UploadServiceSimpleTest` to verify: + +- existing role and machine metadata is unchanged; +- the agreed settings-moniker correlation field is emitted for targeted manual profiles; +- CPU, memory, request, file, JMX, and legacy behaviors do not gain incorrect targeted metadata; +- rejected or failed profiles do not report successful targeted completion. + +### Smoke tests + +Extend the fake settings service in: + +- +`smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/ProfilerState.java` +- +`smoke-tests/framework/src/main/java/com/microsoft/applicationinsights/smoketest/fakeingestion/MockedProfilerSettingsServlet.java` + +Add JavaProfiler smoke scenarios using the configured identity `testrolename` / `testroleinstance`: + +1. Matching role starts one JFR profile and upload. +2. Matching role-qualified instance starts one profile. +3. Same instance name under a different role does not start a profile. +4. Unmatched role does not start a profile. +5. Expired plan does not start a profile. +6. Repeated settings/moniker starts only once. +7. Legacy `collectionPlan` still starts a broadcast profile. +8. Old settings without the new object remain valid. + +Run a focused environment first, for example Java 17, before the normal supported Java matrix. Keep +durations short enough for reliable smoke execution while respecting the minimum duration contract. + +### Cross-repository end-to-end tests + +Before portal enablement, validate against a deployed ServiceProfiler environment: + +- targeted single instance; +- duplicate instance names in different roles; +- whole-role targeting; +- unmatched Java agent; +- expiration before poll; +- repeated unchanged and changed settings; +- mixed old/new Java and .NET fleet; +- moniker-correlated result visible in the portal; +- legacy broadcast still reaches compatible Java and .NET agents. + +## 7. Compatibility and security + +### Compatibility + +- The settings endpoint and v4 document remain unchanged except for the optional object. +- Older Java agents continue sending `1.0.0`, ignore the unknown object, observe an empty legacy + plan, and do nothing. +- New Java agents accept documents without the object and preserve legacy broadcast behavior. +- .NET agents are unchanged and remain outside this repository. +- The new Java models must tolerate additive future fields. +- Java 8 source/runtime compatibility must be preserved despite testing newer JVMs. + +### Security and operational safety + +- Continue using the existing authenticated ServiceProfiler endpoint and HTTP pipeline. +- Do not accept expiration or moniker from local untrusted inputs; they arrive through the existing + service settings channel. +- Treat malformed server state as non-actionable rather than as broadcast. +- Enforce duration limits in the agent even though the service validates them. +- Retain active-recording and global-cooldown controls to bound CPU, disk, memory, and upload + impact. +- Do not log credentials, full settings payloads, or complete target lists. +- Continue writing recordings only under the configured writable temporary directory and deleting + them according to existing upload behavior. + +## 8. Deployment and rollback + +Implement across repositories in this order: + +1. Complete and validate the Java agent consumer with the portal feature flag still disabled. +2. Publish the first compatible Java heartbeat `sdkVersion` and accepted prefixes to the portal + owner. +3. Deploy ServiceProfiler's `2.0.0`-to-v4 routing support before any production Java agent begins + advertising `2.0.0`. +4. Release the Java agent consumer. +5. Deploy the ServiceProfiler DataPlane targeted write endpoint. +6. Run cross-repository end-to-end tests against the released Java agent. +7. Populate and validate the portal capability policy. +8. Enable `profilerTargetedProfileNow` incrementally and monitor decision, recording, and upload + outcomes. + +This refines the cross-repository statement "Java client first" into implementation order versus +production dependency order. The Java implementation must be ready first, but a released agent that +sends `2.0.0` depends on the service accepting that value. If ServiceProfiler cannot deploy protocol +routing before the public Java release, the Java client needs an agreed compatibility mechanism, +such as temporarily polling with `1.0.0`; do not silently add fallback behavior without +service-owner approval. + +Rollback options: + +- Disable the portal feature flag to stop new targeted writes. +- Roll back the ServiceProfiler targeted endpoints while retaining additive read compatibility. +- Roll back the Java agent release; targeted settings remain inert because legacy `collectionPlan` + is empty. +- Preserve legacy broadcast throughout rollout and rollback. + +No settings migration, Cosmos migration, or local configuration migration is required. + +## 9. Risks and mitigations + +| Risk | Mitigation | +|---------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| Poll latency consumes much of the five-minute expiration window. | Keep existing bounded polling; measure received-to-start latency and reject expired plans. | +| Service returns 304 for unchanged settings and the agent treats it as an error. | Handle 304 as an empty successful poll and add focused HTTP tests. | +| A Java agent advertises `2.0.0` before ServiceProfiler accepts it. | Deploy version routing first or agree on a temporary compatibility mechanism before release. | +| Role identity shown by heartbeat differs from profiler identity. | Match using the same resolved `Configuration.role` values used by Java telemetry; test App Service and runtime configuration paths. | +| Duplicate machine names cause over-selection. | Require role and name together for instance targeting. | +| Replayed settings execute twice. | Check expiration and use bounded moniker deduplication before dispatch. | +| A targeted request is blocked by another profile or cooldown. | Preserve safety behavior and expose a distinct decision outcome. | +| Targeted moniker cannot be correlated to uploaded results. | Resolve and test the ServiceProfiler artifact metadata contract before release. | +| Portal offers unsupported Java versions. | Publish an explicit first supported `sdkVersion`; keep the portal threshold unset until then. | +| Role targeting reaches agents absent from current inventory. | Accept this as an eventually consistent portal limitation; agent matching remains exact against the requested role. | + +## 10. Open questions and release gates + +The following must be resolved before implementation is considered production-ready: + +1. **Moniker propagation:** Which profile artifact, index, or telemetry field must carry + `settingsMoniker` for portal progress and result correlation? +2. **Capability identity:** What exact heartbeat `sdkVersion` prefixes identify this agent, and what + released version is the first supported minimum? +3. **Blocked request outcome:** Does ServiceProfiler require a machine-readable acknowledgement when + a selected request is blocked by an active recording or global cooldown, or are agent diagnostics + sufficient? +4. **Deduplication bounds:** Confirm the moniker tracker retention period and maximum capacity. The + proposal is a short in-memory window comfortably exceeding the five-minute request expiration. +5. **Runtime role changes:** Confirm whether environments that apply role identity after profiler + initialization require the profiler's matching identity to be refreshable instead of captured + once. +6. **Protocol deployment sequencing:** Confirm that ServiceProfiler will accept + `featureVersion=2.0.0` before the first production Java agent advertises it; otherwise define the + approved transition behavior. + +Items 1, 2, and 6 are release blockers because the portal contract depends on result correlation and +version-based capability filtering, and the Java agent must retain access to remote profiler +settings during rollout. + +## 11. Implementation milestones + +1. **Contract-ready:** Java models deserialize both target shapes and the client advertises settings + protocol `2.0.0`. +2. **Selection-ready:** Matching and fail-closed validation pass the complete unit matrix. +3. **Execution-ready:** Selected plans reuse the manual JFR path with expiration, bounded + exactly-once behavior, duration limits, and existing safety controls. +4. **Correlation-ready:** Targeted monikers are visible on the agreed service output and diagnostics + distinguish every terminal outcome. +5. **E2E-ready:** Java smoke tests and mixed-fleet ServiceProfiler tests pass. +6. **Rollout-ready:** The Java release version and heartbeat capability policy are published, the + DataPlane service route is deployed, and the portal feature flag remains the final enablement + control. + +## 12. Consequences + +### Positive + +- Targeting is additive and reuses mature profiler recording and upload paths. +- No direct connectivity to customer workloads is introduced. +- Role and instance matching is deterministic and local to the agent. +- Existing Java and .NET broadcast behavior remains available. +- Rollout can be controlled independently through agent version, service deployment, and portal + feature flag. + +### Negative + +- Execution latency remains bounded by polling rather than being immediate push delivery. +- Role targeting can reach agents missing from the portal's eventually consistent inventory. +- Exactly-once behavior is process-local; expiration is required to prevent replay after restart. +- Additional diagnostics and cross-repository release coordination are required. +- The current Java upload path does not yet expose the settings moniker, so correlation needs an + explicit contract change. + +## 13. Alternatives considered + +### Filter settings server-side by agent identity + +Rejected. The settings request does not carry cloud role, server-side inventory is unavailable, and +adding identity-aware responses would complicate caching and change detection. + +### Encode targets in the legacy collection-plan string + +Rejected. Older profilers could misinterpret the request, the format is difficult to evolve safely, +and structured validation would be weaker. + +### Implement a separate targeted scheduler + +Rejected. It would duplicate expiration, deduplication, JFR recording, cooldown, and upload +behavior. Mapping a selected typed plan into the existing manual path is smaller and safer. + +### Persist executed monikers to disk + +Rejected for the initial implementation. Service-generated expiration provides the cross-restart +boundary, while disk persistence adds lifecycle, locking, cleanup, and read-only-filesystem +concerns. + +### Bypass global cooldown for portal-targeted requests + +Rejected. A portal request must not override process safety limits or create overlapping JFR +recordings. 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 }