diff --git a/commons/src/main/java/com/navercorp/pinpoint/common/trace/ServiceType.java b/commons/src/main/java/com/navercorp/pinpoint/common/trace/ServiceType.java
index e4088676608e..4685fe939faf 100644
--- a/commons/src/main/java/com/navercorp/pinpoint/common/trace/ServiceType.java
+++ b/commons/src/main/java/com/navercorp/pinpoint/common/trace/ServiceType.java
@@ -33,9 +33,11 @@
*
| 3 | UNKNOWN_GROUP |
* | 5 | TEST |
* | 7 | COLLECTOR |
+ * | 10 | SERVICE |
* | 100 | ASYNC |
* | 500 | SDK |
* | 510 | SDK_ASYNC |
+ *
*
*
*
@@ -373,6 +375,8 @@ public interface ServiceType {
ServiceType COLLECTOR = of(7, "COLLECTOR");
+ ServiceType SERVICE = of(10, "SERVICE");
+
ServiceType ASYNC = of(100, "ASYNC");
// Java applications, WAS
diff --git a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/config/MapControllerConfiguration.java b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/config/MapControllerConfiguration.java
index 0e85cd5876ad..7b9f4e61c675 100644
--- a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/config/MapControllerConfiguration.java
+++ b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/config/MapControllerConfiguration.java
@@ -29,6 +29,8 @@
import com.navercorp.pinpoint.web.applicationmap.service.TraceIndexService;
import com.navercorp.pinpoint.web.applicationmap.servicemap.ServiceMappingProperties;
import com.navercorp.pinpoint.web.applicationmap.servicemap.ServiceResolver;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationProperties;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationResolver;
import com.navercorp.pinpoint.web.component.ApplicationFactory;
import com.navercorp.pinpoint.web.config.ConfigProperties;
import com.navercorp.pinpoint.web.filter.FilterBuilder;
@@ -43,7 +45,7 @@
import java.util.List;
@Configuration
-@EnableConfigurationProperties(ServiceMappingProperties.class)
+@EnableConfigurationProperties({ServiceMappingProperties.class, VirtualApplicationProperties.class})
public class MapControllerConfiguration {
@Bean
@@ -54,13 +56,23 @@ public ServiceResolver serviceResolver(ServiceMappingProperties serviceMappingPr
return new ServiceResolver(serviceMappingProperties.getMockMappings());
}
+ @Bean
+ public VirtualApplicationResolver virtualApplicationResolver(VirtualApplicationProperties properties,
+ ApplicationValidator applicationValidator) {
+ if (!properties.isMockEnabled()) {
+ return VirtualApplicationResolver.emptyResolver();
+ }
+ return VirtualApplicationResolver.of(properties.getMappings(), applicationValidator);
+ }
+
@Bean
public MapController mapController(MapProperties mapProperties,
MapService mapService,
ApplicationValidator applicationValidator,
+ VirtualApplicationResolver virtualApplicationResolver,
ConfigProperties configProperties) {
Duration maxPeriod = Duration.ofDays(configProperties.getServerMapPeriodMax());
- return new MapController(mapProperties, mapService, applicationValidator, maxPeriod);
+ return new MapController(mapProperties, mapService, applicationValidator, virtualApplicationResolver, maxPeriod);
}
@Bean
diff --git a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/controller/MapController.java b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/controller/MapController.java
index 02dcf12ec483..4029dbcc09b9 100644
--- a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/controller/MapController.java
+++ b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/controller/MapController.java
@@ -35,6 +35,7 @@
import com.navercorp.pinpoint.web.applicationmap.servicemap.ServiceMapViewBuilder;
import com.navercorp.pinpoint.web.applicationmap.view.LinkRender;
import com.navercorp.pinpoint.web.applicationmap.view.NodeRender;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationResolver;
import com.navercorp.pinpoint.web.util.ApplicationValidator;
import com.navercorp.pinpoint.web.vo.Application;
import com.navercorp.pinpoint.web.vo.SearchOption;
@@ -71,6 +72,7 @@ public class MapController {
private final MapService mapService;
private final RangeValidator rangeValidator;
private final ApplicationValidator applicationValidator;
+ private final VirtualApplicationResolver virtualApplicationResolver;
private static final int DEFAULT_MAX_SEARCH_DEPTH = 4;
@@ -78,10 +80,12 @@ public MapController(
MapProperties mapProperties,
MapService mapService,
ApplicationValidator applicationValidator,
+ VirtualApplicationResolver virtualApplicationResolver,
Duration limitDay) {
this.mapProperties = Objects.requireNonNull(mapProperties, "mapProperties");
this.mapService = Objects.requireNonNull(mapService, "mapService");
this.applicationValidator = Objects.requireNonNull(applicationValidator, "applicationValidator");
+ this.virtualApplicationResolver = Objects.requireNonNull(virtualApplicationResolver, "virtualApplicationResolver");
this.rangeValidator = new ForwardRangeValidator(Objects.requireNonNull(limitDay, "limitDay"));
}
@@ -113,9 +117,10 @@ public MapWrap getServerMapData(
.build(searchForm.getCallerRange(), searchForm.getCalleeRange(), searchForm.isBidirectional(), searchForm.isWasOnly());
final Application application = getApplication(appForm);
+ final List applications = virtualApplicationResolver.resolve(application);
final MapServiceOption option = new MapServiceOption
- .Builder(application, timeWindow, searchOption)
+ .Builder(applications, timeWindow, searchOption)
.setUseStatisticsAgentState(useStatisticsAgentState)
.build();
@@ -154,9 +159,10 @@ public MapWrap getServiceMapData(
final Set keepServices = getKeepServices(keepServiceNames);
final Application application = getApplication(appForm);
+ final List applications = virtualApplicationResolver.resolve(application);
final MapServiceOption option = new MapServiceOption
- .Builder(application, timeWindow, searchOption)
+ .Builder(applications, timeWindow, searchOption)
.setUseStatisticsAgentState(useStatisticsAgentState)
.build();
diff --git a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationProperties.java b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationProperties.java
new file mode 100644
index 000000000000..d8020d10e5dd
--- /dev/null
+++ b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationProperties.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 NAVER Corp.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.navercorp.pinpoint.web.applicationmap.virtualapplication;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@ConfigurationProperties(prefix = "web.virtual-service")
+public class VirtualApplicationProperties {
+
+ private boolean mockEnabled = false;
+ private List mappings = new ArrayList<>();
+
+ public boolean isMockEnabled() {
+ return mockEnabled;
+ }
+
+ public void setMockEnabled(boolean mockEnabled) {
+ this.mockEnabled = mockEnabled;
+ }
+
+ public List getMappings() {
+ return mappings;
+ }
+
+ public void setMappings(List mappings) {
+ this.mappings = mappings;
+ }
+
+ public static class VirtualApplicationRule {
+ private String virtualServiceName;
+ private List members = new ArrayList<>();
+
+ public String getVirtualServiceName() {
+ return virtualServiceName;
+ }
+
+ public void setVirtualServiceName(String virtualServiceName) {
+ this.virtualServiceName = virtualServiceName;
+ }
+
+ public List getMembers() {
+ return members;
+ }
+
+ public void setMembers(List members) {
+ this.members = members;
+ }
+ }
+
+ public static class ApplicationRef {
+ private String name;
+ private String serviceType;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getServiceType() {
+ return serviceType;
+ }
+
+ public void setServiceType(String serviceType) {
+ this.serviceType = serviceType;
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolver.java b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolver.java
new file mode 100644
index 000000000000..be672e1960c5
--- /dev/null
+++ b/web/src/main/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolver.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2026 NAVER Corp.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.navercorp.pinpoint.web.applicationmap.virtualapplication;
+
+import com.navercorp.pinpoint.common.trace.ServiceType;
+import com.navercorp.pinpoint.common.util.CollectionUtils;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationProperties.ApplicationRef;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationProperties.VirtualApplicationRule;
+import com.navercorp.pinpoint.web.util.ApplicationValidator;
+import com.navercorp.pinpoint.web.vo.Application;
+import org.springframework.util.StringUtils;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class VirtualApplicationResolver {
+
+ private static final String SERVICE_TYPE_NAME = ServiceType.SERVICE.getName();
+ private static final VirtualApplicationResolver EMPTY = new VirtualApplicationResolver(Map.of());
+
+ private final Map> table;
+
+ VirtualApplicationResolver(Map> table) {
+ this.table = Objects.requireNonNull(table, "table");
+ }
+
+ public static VirtualApplicationResolver emptyResolver() {
+ return EMPTY;
+ }
+
+ public static VirtualApplicationResolver of(List rules, ApplicationValidator validator) {
+ Objects.requireNonNull(rules, "rules");
+ Objects.requireNonNull(validator, "validator");
+
+ Map> table = new LinkedHashMap<>();
+ for (VirtualApplicationRule rule : rules) {
+ String virtualServiceName = rule.getVirtualServiceName();
+ if (!StringUtils.hasLength(virtualServiceName)) {
+ throw new IllegalArgumentException("virtualServiceName must not be empty");
+ }
+ List members = rule.getMembers();
+ if (CollectionUtils.isEmpty(members)) {
+ throw new IllegalArgumentException("members must not be empty for virtualServiceName=" + virtualServiceName);
+ }
+ List applications = members.stream()
+ .map(m -> validator.newApplication(m.getName(), m.getServiceType()))
+ .toList();
+ if (table.put(virtualServiceName, applications) != null) {
+ throw new IllegalArgumentException("duplicate virtualServiceName=" + virtualServiceName);
+ }
+ }
+ return new VirtualApplicationResolver(Map.copyOf(table));
+ }
+
+ public List resolve(Application input) {
+ Objects.requireNonNull(input, "input");
+ if (!SERVICE_TYPE_NAME.equals(input.getServiceType().getName())) {
+ return List.of(input);
+ }
+ List expanded = table.get(input.getApplicationName());
+ if (expanded != null) {
+ return expanded;
+ }
+ return List.of(input);
+ }
+}
\ No newline at end of file
diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml
index 664a0a82fdf4..4cac3cc096f1 100644
--- a/web/src/main/resources/application.yml
+++ b/web/src/main/resources/application.yml
@@ -111,6 +111,29 @@ pinpoint.web.websocket:
max-binary-message-buffer-size:
max-text-message-buffer-size:
+# Service map application-to-service mock mapping rules
+#web.servicemap:
+# mock-enabled: false
+# mock-mappings:
+# - prefix:
+# - "BLOG-API"
+# - "BLOG-MAIN"
+# serviceName: "BLOG"
+# serviceUid: 100
+# - prefix:
+# - "CAFE-NOTICE"
+# - "CAFE-MANAGE-API"
+# serviceName: "CAFE"
+# serviceUid: 200
+
+## Service map virtual-service mock mapping rules
+#web.virtual-service:
+# mock-enabled: true
+# mappings:
+# - virtual-service-name: CAFE-SERVICE
+# members:
+# - { name: CAFE.MAIN, service-type: SPRING_BOOT }
+# - { name: CAFE.API, service-type: SPRING_BOOT }
---
spring.config.activate.on-profile: local
diff --git a/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/controller/MapControllerTest.java b/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/controller/MapControllerTest.java
index 01c1d1c8910e..cbcfeeca9c60 100644
--- a/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/controller/MapControllerTest.java
+++ b/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/controller/MapControllerTest.java
@@ -20,6 +20,7 @@
import com.navercorp.pinpoint.web.applicationmap.ApplicationMap;
import com.navercorp.pinpoint.web.applicationmap.config.MapProperties;
import com.navercorp.pinpoint.web.applicationmap.service.MapService;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationResolver;
import com.navercorp.pinpoint.web.util.ApplicationValidator;
import com.navercorp.pinpoint.web.vo.Application;
import org.apache.logging.log4j.LogManager;
@@ -60,7 +61,8 @@ public void setup() {
MapProperties mapProperties = new MapProperties();
Duration duration = Duration.ofMinutes(1);
- MapController controller = new MapController(mapProperties, mapService, applicationValidator, duration);
+ VirtualApplicationResolver virtualApplicationResolver = VirtualApplicationResolver.emptyResolver();
+ MapController controller = new MapController(mapProperties, mapService, applicationValidator, virtualApplicationResolver, duration);
when(applicationValidator.newApplication(any(), anyInt(), any()))
.thenReturn(new Application("test", ServiceType.STAND_ALONE));
diff --git a/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolverTest.java b/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolverTest.java
new file mode 100644
index 000000000000..48776055fd82
--- /dev/null
+++ b/web/src/test/java/com/navercorp/pinpoint/web/applicationmap/virtualapplication/VirtualApplicationResolverTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 NAVER Corp.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.navercorp.pinpoint.web.applicationmap.virtualapplication;
+
+import com.navercorp.pinpoint.common.trace.ServiceType;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationProperties.ApplicationRef;
+import com.navercorp.pinpoint.web.applicationmap.virtualapplication.VirtualApplicationProperties.VirtualApplicationRule;
+import com.navercorp.pinpoint.web.util.ApplicationValidator;
+import com.navercorp.pinpoint.web.vo.Application;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class VirtualApplicationResolverTest {
+
+ @Test
+ void resolve_match_expandsToConfiguredMembers() {
+ Application memberA = new Application("CAFE.A", ServiceType.STAND_ALONE);
+ Application memberB = new Application("CAFE.B", ServiceType.STAND_ALONE);
+
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ when(validator.newApplication("CAFE.A", "SPRING_BOOT")).thenReturn(memberA);
+ when(validator.newApplication("CAFE.B", "TOMCAT")).thenReturn(memberB);
+
+ VirtualApplicationRule rule = newRule("CAFESERVICE",
+ ref("CAFE.A", "SPRING_BOOT"),
+ ref("CAFE.B", "TOMCAT"));
+
+ VirtualApplicationResolver resolver = VirtualApplicationResolver.of(List.of(rule), validator);
+
+ Application input = new Application("CAFESERVICE", serviceTypeNamed("SERVICE"));
+ List result = resolver.resolve(input);
+
+ assertThat(result).containsExactly(memberA, memberB);
+ }
+
+ @Test
+ void resolve_noMatch_returnsSingletonOfInput() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ when(validator.newApplication("CAFE.A", "SPRING_BOOT"))
+ .thenReturn(new Application("CAFE.A", ServiceType.STAND_ALONE));
+
+ VirtualApplicationResolver resolver = VirtualApplicationResolver.of(
+ List.of(newRule("CAFESERVICE", ref("CAFE.A", "SPRING_BOOT"))),
+ validator);
+
+ Application input = new Application("UNKNOWN_APP", serviceTypeNamed("SERVICE"));
+ List result = resolver.resolve(input);
+
+ assertThat(result).containsExactly(input);
+ }
+
+ @Test
+ void resolve_nonServiceType_returnsSingletonOfInput() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ when(validator.newApplication("CAFE.A", "SPRING_BOOT"))
+ .thenReturn(new Application("CAFE.A", ServiceType.STAND_ALONE));
+
+ VirtualApplicationResolver resolver = VirtualApplicationResolver.of(
+ List.of(newRule("CAFESERVICE", ref("CAFE.A", "SPRING_BOOT"))),
+ validator);
+
+ Application input = new Application("CAFESERVICE", serviceTypeNamed("SPRING_BOOT"));
+ assertThat(resolver.resolve(input)).containsExactly(input);
+ }
+
+ @Test
+ void emptyResolver_alwaysReturnsSingletonOfInput() {
+ VirtualApplicationResolver resolver = VirtualApplicationResolver.emptyResolver();
+
+ Application input = new Application("ANY", ServiceType.STAND_ALONE);
+ assertThat(resolver.resolve(input)).containsExactly(input);
+ }
+
+ @Test
+ void of_validatesMembers_atBootTime() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ Application memberA = new Application("CAFE.A", ServiceType.STAND_ALONE);
+ when(validator.newApplication("CAFE.A", "SPRING_BOOT")).thenReturn(memberA);
+
+ VirtualApplicationResolver.of(
+ List.of(newRule("CAFESERVICE", ref("CAFE.A", "SPRING_BOOT"))),
+ validator);
+
+ verify(validator).newApplication("CAFE.A", "SPRING_BOOT");
+ }
+
+ @Test
+ void of_rejects_emptyMembers() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ VirtualApplicationRule rule = newRule("CAFESERVICE");
+
+ assertThatThrownBy(() -> VirtualApplicationResolver.of(List.of(rule), validator))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("members must not be empty");
+ }
+
+ @Test
+ void of_rejects_emptyVirtualServiceName() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ VirtualApplicationRule rule = newRule("", ref("CAFE.A", "SPRING_BOOT"));
+
+ assertThatThrownBy(() -> VirtualApplicationResolver.of(List.of(rule), validator))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("virtualServiceName must not be empty");
+ }
+
+ @Test
+ void of_rejects_duplicateVirtualServiceName() {
+ ApplicationValidator validator = mock(ApplicationValidator.class);
+ when(validator.newApplication("CAFE.A", "SPRING_BOOT"))
+ .thenReturn(new Application("CAFE.A", ServiceType.STAND_ALONE));
+
+ VirtualApplicationRule first = newRule("DUP", ref("CAFE.A", "SPRING_BOOT"));
+ VirtualApplicationRule second = newRule("DUP", ref("CAFE.A", "SPRING_BOOT"));
+
+ assertThatThrownBy(() -> VirtualApplicationResolver.of(List.of(first, second), validator))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("duplicate virtualServiceName");
+ }
+
+ private static VirtualApplicationRule newRule(String virtualServiceName, ApplicationRef... members) {
+ VirtualApplicationRule rule = new VirtualApplicationRule();
+ rule.setVirtualServiceName(virtualServiceName);
+ rule.setMembers(List.of(members));
+ return rule;
+ }
+
+ private static ApplicationRef ref(String name, String serviceType) {
+ ApplicationRef ref = new ApplicationRef();
+ ref.setName(name);
+ ref.setServiceType(serviceType);
+ return ref;
+ }
+
+ private static ServiceType serviceTypeNamed(String name) {
+ ServiceType serviceType = mock(ServiceType.class);
+ when(serviceType.getName()).thenReturn(name);
+ return serviceType;
+ }
+}
\ No newline at end of file