Introduction
Synthetic monitoring involves simulating user interactions and transactions to proactively monitor application health, performance, and functionality. Unlike real-user monitoring, synthetic tests run on a schedule from various locations to detect issues before users are affected.
This guide covers building a comprehensive synthetic monitoring framework in Java for web applications, APIs, and business transactions.
Architecture Overview
[Synthetic Tests] → [Test Executors] → [Applications] → [Metrics Collector] → [Dashboard/Alerts] ↓ ↓ ↓ ↓ ↓ HTTP Checks Browser Microservices Metrics Storage PagerDuty API Validation Automation Databases Log Aggregator Slack/Email Business Flows Performance External APIs Time Series DB Kibana
Step 1: Project Dependencies
<properties> <spring-boot.version>3.2.0</spring-boot.version> <selenium.version>4.15.0</selenium.version> <micrometer.version>1.12.0</micrometer.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- Scheduling --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- HTTP Client --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- Selenium for Browser Automation --> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-java</artifactId> <version>${selenium.version}</version> </dependency> <dependency> <groupId>org.seleniumhq.selenium</groupId> <artifactId>selenium-chrome-driver</artifactId> <version>${selenium.version}</version> </dependency> <!-- Metrics --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-core</artifactId> <version>${micrometer.version}</version> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> <version>${micrometer.version}</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.0</version> </dependency> <!-- Database --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <version>${spring-boot.version}</version> <scope>test</scope> </dependency> </dependencies> Step 2: Configuration
application.yml
synthetic: monitoring: enabled: true execution: thread-pool-size: 10 timeout-seconds: 300 retry-attempts: 2 retry-delay-ms: 5000 # Browser automation browser: headless: true window-width: 1920 window-height: 1080 implicit-wait-seconds: 10 page-load-timeout-seconds: 30 # Notifications alerts: slack-webhook-url: ${SLACK_WEBHOOK_URL:} pagerduty-routing-key: ${PAGERDUTY_ROUTING_KEY:} email-recipients: ${ALERT_EMAILS:} # Storage storage: retain-days: 30 cleanup-enabled: true # Metrics management: endpoints: web: exposure: include: health,metrics,prometheus endpoint: health: show-details: always metrics: enabled: true # Logging logging: level: com.example.synthetic: DEBUG pattern: level: "%5p [%X{traceId:-},%X{testId:-}]" # Scheduled tasks spring: quartz: job-store-type: jdbc jdbc: initialize-schema: always datasource: url: jdbc:h2:file:./data/synthetic-monitoring driverClassName: org.h2.Driver username: sa password: "" jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: update show-sql: false Step 3: Core Data Models
Test Definition Models
MonitorTest.java
package com.example.synthetic.model; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; import java.util.Map; @Entity @Table(name = "monitor_tests") @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "test_type") @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = HttpTest.class, name = "HTTP"), @JsonSubTypes.Type(value = BrowserTest.class, name = "BROWSER"), @JsonSubTypes.Type(value = ApiValidationTest.class, name = "API_VALIDATION"), @JsonSubTypes.Type(value = BusinessFlowTest.class, name = "BUSINESS_FLOW") }) public abstract class MonitorTest { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Column(nullable = false) private String name; private String description; @NotBlank @Column(nullable = false) private String targetUrl; @NotNull @Enumerated(EnumType.STRING) @Column(nullable = false) private TestFrequency frequency; @NotNull @Enumerated(EnumType.STRING) @Column(nullable = false) private TestPriority priority; private boolean enabled = true; private int timeoutSeconds = 300; private int retryAttempts = 2; @ElementCollection @CollectionTable(name = "test_labels", joinColumns = @JoinColumn(name = "test_id")) @MapKeyColumn(name = "label_key") @Column(name = "label_value") private Map<String, String> labels; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updatedAt; // Pre-persist and pre-update methods @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public String getTargetUrl() { return targetUrl; } public void setTargetUrl(String targetUrl) { this.targetUrl = targetUrl; } public TestFrequency getFrequency() { return frequency; } public void setFrequency(TestFrequency frequency) { this.frequency = frequency; } public TestPriority getPriority() { return priority; } public void setPriority(TestPriority priority) { this.priority = priority; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public int getTimeoutSeconds() { return timeoutSeconds; } public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; } public int getRetryAttempts() { return retryAttempts; } public void setRetryAttempts(int retryAttempts) { this.retryAttempts = retryAttempts; } public Map<String, String> getLabels() { return labels; } public void setLabels(Map<String, String> labels) { this.labels = labels; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } } @Entity @DiscriminatorValue("HTTP") class HttpTest extends MonitorTest { private HttpMethod method = HttpMethod.GET; @Column(length = 2000) private String requestBody; @ElementCollection @CollectionTable(name = "http_headers", joinColumns = @JoinColumn(name = "test_id")) @MapKeyColumn(name = "header_name") @Column(name = "header_value") private Map<String, String> headers; private Integer expectedStatusCode = 200; @Column(length = 1000) private String expectedResponsePattern; private Long maxResponseTimeMs = 5000L; // Getters and Setters public HttpMethod getMethod() { return method; } public void setMethod(HttpMethod method) { this.method = method; } public String getRequestBody() { return requestBody; } public void setRequestBody(String requestBody) { this.requestBody = requestBody; } public Map<String, String> getHeaders() { return headers; } public void setHeaders(Map<String, String> headers) { this.headers = headers; } public Integer getExpectedStatusCode() { return expectedStatusCode; } public void setExpectedStatusCode(Integer expectedStatusCode) { this.expectedStatusCode = expectedStatusCode; } public String getExpectedResponsePattern() { return expectedResponsePattern; } public void setExpectedResponsePattern(String expectedResponsePattern) { this.expectedResponsePattern = expectedResponsePattern; } public Long getMaxResponseTimeMs() { return maxResponseTimeMs; } public void setMaxResponseTimeMs(Long maxResponseTimeMs) { this.maxResponseTimeMs = maxResponseTimeMs; } } @Entity @DiscriminatorValue("BROWSER") class BrowserTest extends MonitorTest { @Column(length = 2000) private String script; private String browserType = "CHROME"; private boolean takeScreenshotOnFailure = true; private Long maxLoadTimeMs = 10000L; // Getters and Setters public String getScript() { return script; } public void setScript(String script) { this.script = script; } public String getBrowserType() { return browserType; } public void setBrowserType(String browserType) { this.browserType = browserType; } public boolean isTakeScreenshotOnFailure() { return takeScreenshotOnFailure; } public void setTakeScreenshotOnFailure(boolean takeScreenshotOnFailure) { this.takeScreenshotOnFailure = takeScreenshotOnFailure; } public Long getMaxLoadTimeMs() { return maxLoadTimeMs; } public void setMaxLoadTimeMs(Long maxLoadTimeMs) { this.maxLoadTimeMs = maxLoadTimeMs; } } Enums and Supporting Classes
TestEnums.java
package com.example.synthetic.model; public enum TestFrequency { EVERY_MINUTE(60), EVERY_5_MINUTES(300), EVERY_15_MINUTES(900), EVERY_30_MINUTES(1800), HOURLY(3600), EVERY_6_HOURS(21600), DAILY(86400); private final int seconds; TestFrequency(int seconds) { this.seconds = seconds; } public int getSeconds() { return seconds; } public String getCronExpression() { switch (this) { case EVERY_MINUTE: return "0 * * * * ?"; case EVERY_5_MINUTES: return "0 */5 * * * ?"; case EVERY_15_MINUTES: return "0 */15 * * * ?"; case EVERY_30_MINUTES: return "0 */30 * * * ?"; case HOURLY: return "0 0 * * * ?"; case EVERY_6_HOURS: return "0 0 */6 * * ?"; case DAILY: return "0 0 0 * * ?"; default: return "0 0 * * * ?"; } } } public enum TestPriority { LOW, MEDIUM, HIGH, CRITICAL } public enum HttpMethod { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } public enum TestStatus { SUCCESS, FAILURE, TIMEOUT, ERROR, SKIPPED } Test Execution Results
TestExecutionResult.java
package com.example.synthetic.model; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.persistence.*; import java.time.LocalDateTime; import java.util.Map; @Entity @Table(name = "test_execution_results") public class TestExecutionResult { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "test_id", nullable = false) private MonitorTest test; @Enumerated(EnumType.STRING) @Column(nullable = false) private TestStatus status; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @Column(nullable = false) private LocalDateTime executionTime; private Long responseTimeMs; private Integer statusCode; @Column(length = 500) private String errorMessage; @Column(length = 4000) private String responseBody; @ElementCollection @CollectionTable(name = "execution_metrics", joinColumns = @JoinColumn(name = "execution_id")) @MapKeyColumn(name = "metric_name") @Column(name = "metric_value") private Map<String, Double> metrics; private String screenshotPath; private String location; // Where test was executed from @Column(length = 1000) private String additionalInfo; // Constructors public TestExecutionResult() {} public TestExecutionResult(MonitorTest test, TestStatus status) { this.test = test; this.status = status; this.executionTime = LocalDateTime.now(); } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public MonitorTest getTest() { return test; } public void setTest(MonitorTest test) { this.test = test; } public TestStatus getStatus() { return status; } public void setStatus(TestStatus status) { this.status = status; } public LocalDateTime getExecutionTime() { return executionTime; } public void setExecutionTime(LocalDateTime executionTime) { this.executionTime = executionTime; } public Long getResponseTimeMs() { return responseTimeMs; } public void setResponseTimeMs(Long responseTimeMs) { this.responseTimeMs = responseTimeMs; } public Integer getStatusCode() { return statusCode; } public void setStatusCode(Integer statusCode) { this.statusCode = statusCode; } public String getErrorMessage() { return errorMessage; } public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } public String getResponseBody() { return responseBody; } public void setResponseBody(String responseBody) { this.responseBody = responseBody; } public Map<String, Double> getMetrics() { return metrics; } public void setMetrics(Map<String, Double> metrics) { this.metrics = metrics; } public String getScreenshotPath() { return screenshotPath; } public void setScreenshotPath(String screenshotPath) { this.screenshotPath = screenshotPath; } public String getLocation() { return location; } public void setLocation(String location) { this.location = location; } public String getAdditionalInfo() { return additionalInfo; } public void setAdditionalInfo(String additionalInfo) { this.additionalInfo = additionalInfo; } } Step 4: Test Executors
Base Test Executor
TestExecutor.java
package com.example.synthetic.executor; import com.example.synthetic.model.MonitorTest; import com.example.synthetic.model.TestExecutionResult; import com.example.synthetic.model.TestStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.Instant; import java.util.concurrent.Callable; public abstract class TestExecutor { protected final Logger logger = LoggerFactory.getLogger(getClass()); public abstract boolean supports(MonitorTest test); public TestExecutionResult execute(MonitorTest test) { Instant start = Instant.now(); TestExecutionResult result = new TestExecutionResult(test, TestStatus.SUCCESS); try { logger.info("Executing test: {} - {}", test.getId(), test.getName()); result = doExecute(test); } catch (Exception e) { logger.error("Test execution failed for test: {}", test.getName(), e); result.setStatus(TestStatus.ERROR); result.setErrorMessage(e.getMessage()); } finally { long duration = Duration.between(start, Instant.now()).toMillis(); result.setResponseTimeMs(duration); logger.info("Test completed: {} - Status: {} - Duration: {}ms", test.getName(), result.getStatus(), duration); } return result; } protected abstract TestExecutionResult doExecute(MonitorTest test) throws Exception; protected <T> T executeWithTimeout(Callable<T> callable, int timeoutSeconds) throws Exception { // Implementation with timeout handling // Using CompletableFuture with timeout return callable.call(); // Simplified - implement proper timeout } } HTTP Test Executor
HttpTestExecutor.java
package com.example.synthetic.executor; import com.example.synthetic.model.HttpTest; import com.example.synthetic.model.MonitorTest; import com.example.synthetic.model.TestExecutionResult; import com.example.synthetic.model.TestStatus; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; import java.time.Duration; import java.util.Map; import java.util.regex.Pattern; @Component public class HttpTestExecutor extends TestExecutor { private final WebClient webClient; public HttpTestExecutor(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.build(); } @Override public boolean supports(MonitorTest test) { return test instanceof HttpTest; } @Override protected TestExecutionResult doExecute(MonitorTest test) throws Exception { HttpTest httpTest = (HttpTest) test; TestExecutionResult result = new TestExecutionResult(test, TestStatus.SUCCESS); try { WebClient.RequestBodySpec requestSpec = webClient .method(HttpMethod.valueOf(httpTest.getMethod().name())) .uri(httpTest.getTargetUrl()); // Set headers if (httpTest.getHeaders() != null) { httpTest.getHeaders().forEach(requestSpec::header); } // Set body for POST, PUT, PATCH if (httpTest.getRequestBody() != null && (httpTest.getMethod() == HttpMethod.POST || httpTest.getMethod() == HttpMethod.PUT || httpTest.getMethod() == HttpMethod.PATCH)) { requestSpec.contentType(MediaType.APPLICATION_JSON) .bodyValue(httpTest.getRequestBody()); } String responseBody = requestSpec .retrieve() .bodyToMono(String.class) .timeout(Duration.ofSeconds(httpTest.getTimeoutSeconds())) .block(); result.setStatusCode(200); // Assuming success since no exception result.setResponseBody(truncateResponse(responseBody, 4000)); // Validate response validateResponse(httpTest, result, responseBody); } catch (WebClientResponseException e) { result.setStatusCode(e.getStatusCode().value()); result.setResponseBody(truncateResponse(e.getResponseBodyAsString(), 4000)); if (httpTest.getExpectedStatusCode() != null && httpTest.getExpectedStatusCode() != e.getStatusCode().value()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Expected status " + httpTest.getExpectedStatusCode() + " but got " + e.getStatusCode().value()); } else if (httpTest.getExpectedStatusCode() == null && e.getStatusCode().isError()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("HTTP error: " + e.getStatusCode()); } } catch (Exception e) { result.setStatus(TestStatus.ERROR); result.setErrorMessage(e.getMessage()); } return result; } private void validateResponse(HttpTest test, TestExecutionResult result, String responseBody) { // Check expected status code if (test.getExpectedStatusCode() != null && result.getStatusCode() != test.getExpectedStatusCode()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Expected status " + test.getExpectedStatusCode() + " but got " + result.getStatusCode()); return; } // Check response pattern if (test.getExpectedResponsePattern() != null && responseBody != null) { Pattern pattern = Pattern.compile(test.getExpectedResponsePattern()); if (!pattern.matcher(responseBody).find()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Response pattern not found"); return; } } // Check response time if (test.getMaxResponseTimeMs() != null && result.getResponseTimeMs() > test.getMaxResponseTimeMs()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Response time " + result.getResponseTimeMs() + "ms exceeds maximum " + test.getMaxResponseTimeMs() + "ms"); } } private String truncateResponse(String response, int maxLength) { if (response == null || response.length() <= maxLength) { return response; } return response.substring(0, maxLength - 3) + "..."; } } Browser Test Executor
BrowserTestExecutor.java
package com.example.synthetic.executor; import com.example.synthetic.model.BrowserTest; import com.example.synthetic.model.MonitorTest; import com.example.synthetic.model.TestExecutionResult; import com.example.synthetic.model.TestStatus; import org.openqa.selenium.*; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.support.ui.WebDriverWait; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.Base64; import java.util.HashMap; import java.util.Map; @Component public class BrowserTestExecutor extends TestExecutor { @Value("${synthetic.monitoring.browser.headless:true}") private boolean headless; @Value("${synthetic.monitoring.screenshot.directory:./screenshots}") private String screenshotDirectory; @Override public boolean supports(MonitorTest test) { return test instanceof BrowserTest; } @Override protected TestExecutionResult doExecute(MonitorTest test) throws Exception { BrowserTest browserTest = (BrowserTest) test; WebDriver driver = null; TestExecutionResult result = new TestExecutionResult(test, TestStatus.SUCCESS); try { // Setup WebDriver driver = createWebDriver(); WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30)); // Navigate to target URL long startTime = System.currentTimeMillis(); driver.get(browserTest.getTargetUrl()); long loadTime = System.currentTimeMillis() - startTime; result.setResponseTimeMs(loadTime); // Execute custom script if provided if (browserTest.getScript() != null && !browserTest.getScript().trim().isEmpty()) { executeBrowserScript(driver, browserTest.getScript(), result); } // Check page load performance if (browserTest.getMaxLoadTimeMs() != null && loadTime > browserTest.getMaxLoadTimeMs()) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Page load time " + loadTime + "ms exceeds maximum " + browserTest.getMaxLoadTimeMs() + "ms"); } // Capture performance metrics capturePerformanceMetrics(driver, result); } catch (TimeoutException e) { result.setStatus(TestStatus.TIMEOUT); result.setErrorMessage("Browser operation timed out: " + e.getMessage()); } catch (Exception e) { result.setStatus(TestStatus.ERROR); result.setErrorMessage("Browser test failed: " + e.getMessage()); } finally { // Take screenshot on failure if (driver != null) { if (result.getStatus() != TestStatus.SUCCESS && browserTest.isTakeScreenshotOnFailure()) { String screenshotPath = takeScreenshot(driver, test.getName()); result.setScreenshotPath(screenshotPath); } driver.quit(); } } return result; } private WebDriver createWebDriver() { ChromeOptions options = new ChromeOptions(); if (headless) { options.addArguments("--headless"); } options.addArguments("--no-sandbox"); options.addArguments("--disable-dev-shm-usage"); options.addArguments("--window-size=1920,1080"); options.addArguments("--disable-gpu"); options.addArguments("--disable-extensions"); // Performance logging options.setCapability("goog:loggingPrefs", Map.of("performance", "ALL", "browser", "ALL")); return new ChromeDriver(options); } private void executeBrowserScript(WebDriver driver, String script, TestExecutionResult result) { try { // Simple script execution - extend for more complex scenarios if (script.contains("waitForElement")) { // Example: waitForElement("#submit-button") String selector = script.split("\\(")[1].replace(")", "").replace("\"", "").trim(); WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10)); wait.until(webDriver -> webDriver.findElement(By.cssSelector(selector)).isDisplayed()); } if (script.contains("click")) { String selector = script.split("\\(")[1].replace(")", "").replace("\"", "").trim(); driver.findElement(By.cssSelector(selector)).click(); } if (script.contains("assertText")) { // Example: assertText(".status", "Success") String[] parts = script.split(","); String selector = parts[0].split("\\(")[1].replace("\"", "").trim(); String expectedText = parts[1].replace(")", "").replace("\"", "").trim(); String actualText = driver.findElement(By.cssSelector(selector)).getText(); if (!actualText.contains(expectedText)) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Text assertion failed. Expected: " + expectedText + ", Actual: " + actualText); } } } catch (NoSuchElementException e) { result.setStatus(TestStatus.FAILURE); result.setErrorMessage("Element not found: " + e.getMessage()); } catch (TimeoutException e) { result.setStatus(TestStatus.TIMEOUT); result.setErrorMessage("Element wait timeout: " + e.getMessage()); } } private void capturePerformanceMetrics(WebDriver driver, TestExecutionResult result) { try { JavascriptExecutor js = (JavascriptExecutor) driver; Map<String, Double> metrics = new HashMap<>(); // Navigation timing API Long loadEventEnd = (Long) js.executeScript( "return window.performance.timing.loadEventEnd"); Long navigationStart = (Long) js.executeScript( "return window.performance.timing.navigationStart"); if (loadEventEnd != null && navigationStart != null) { metrics.put("page_load_time", (double)(loadEventEnd - navigationStart)); } // Resource timing Long totalResourceSize = (Long) js.executeScript( "return window.performance.getEntriesByType('resource')" + ".reduce((acc, entry) => acc + entry.transferSize, 0)"); if (totalResourceSize != null) { metrics.put("total_resource_size_kb", totalResourceSize / 1024.0); } result.setMetrics(metrics); } catch (Exception e) { logger.warn("Failed to capture performance metrics", e); } } private String takeScreenshot(WebDriver driver, String testName) { try { Files.createDirectories(Paths.get(screenshotDirectory)); String timestamp = String.valueOf(System.currentTimeMillis()); String filename = testName.replaceAll("[^a-zA-Z0-9]", "_") + "_" + timestamp + ".png"; Path screenshotPath = Paths.get(screenshotDirectory, filename); File screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); Files.copy(screenshot.toPath(), screenshotPath); return screenshotPath.toString(); } catch (Exception e) { logger.error("Failed to take screenshot", e); return null; } } } Step 5: Test Orchestration Service
TestOrchestrationService.java
package com.example.synthetic.service; import com.example.synthetic.executor.TestExecutor; import com.example.synthetic.model.MonitorTest; import com.example.synthetic.model.TestExecutionResult; import com.example.synthetic.model.TestStatus; import com.example.synthetic.repository.MonitorTestRepository; import com.example.synthetic.repository.TestExecutionResultRepository; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @Service public class TestOrchestrationService { private static final Logger logger = LoggerFactory.getLogger(TestOrchestrationService.class); private final List<TestExecutor> testExecutors; private final MonitorTestRepository testRepository; private final TestExecutionResultRepository resultRepository; private final AlertService alertService; private final MeterRegistry meterRegistry; private final Map<Long, CompletableFuture<TestExecutionResult>> runningTests = new ConcurrentHashMap<>(); public TestOrchestrationService(List<TestExecutor> testExecutors, MonitorTestRepository testRepository, TestExecutionResultRepository resultRepository, AlertService alertService, MeterRegistry meterRegistry) { this.testExecutors = testExecutors; this.testRepository = testRepository; this.resultRepository = resultRepository; this.alertService = alertService; this.meterRegistry = meterRegistry; } @Async("testExecutionTaskExecutor") public CompletableFuture<TestExecutionResult> executeTest(Long testId) { return testRepository.findById(testId) .map(this::executeTest) .orElse(CompletableFuture.completedFuture( createErrorResult("Test not found with id: " + testId))); } @Async("testExecutionTaskExecutor") public CompletableFuture<TestExecutionResult> executeTest(MonitorTest test) { // Check if test is already running if (runningTests.containsKey(test.getId())) { logger.warn("Test {} is already running", test.getId()); return runningTests.get(test.getId()); } CompletableFuture<TestExecutionResult> future = CompletableFuture.supplyAsync(() -> { Timer.Sample sample = Timer.start(meterRegistry); TestExecutionResult result = null; try { TestExecutor executor = findExecutor(test); if (executor == null) { return createErrorResult("No executor found for test type: " + test.getClass().getSimpleName()); } result = executor.execute(test); } catch (Exception e) { logger.error("Test execution failed for test: {}", test.getName(), e); result = createErrorResult("Execution error: " + e.getMessage()); } finally { sample.stop(Timer.builder("synthetic.test.execution.time") .tag("testName", test.getName()) .tag("status", result != null ? result.getStatus().name() : "ERROR") .register(meterRegistry)); runningTests.remove(test.getId()); } return result != null ? result : createErrorResult("Unknown execution error"); }); runningTests.put(test.getId(), future); // Process result when complete future.thenAccept(this::processExecutionResult); return future; } @Transactional public void processExecutionResult(TestExecutionResult result) { try { // Save result to database resultRepository.save(result); // Update metrics updateMetrics(result); // Send alert if test failed if (result.getStatus() != TestStatus.SUCCESS) { alertService.sendTestFailureAlert(result); } logger.info("Processed test result for {}: {}", result.getTest().getName(), result.getStatus()); } catch (Exception e) { logger.error("Failed to process test execution result", e); } } public void executeScheduledTests() { List<MonitorTest> scheduledTests = testRepository.findEnabledTestsForExecution( LocalDateTime.now()); logger.info("Executing {} scheduled tests", scheduledTests.size()); scheduledTests.forEach(test -> { try { executeTest(test); } catch (Exception e) { logger.error("Failed to execute scheduled test: {}", test.getName(), e); } }); } private TestExecutor findExecutor(MonitorTest test) { return testExecutors.stream() .filter(executor -> executor.supports(test)) .findFirst() .orElse(null); } private TestExecutionResult createErrorResult(String errorMessage) { TestExecutionResult result = new TestExecutionResult(); result.setStatus(TestStatus.ERROR); result.setErrorMessage(errorMessage); result.setExecutionTime(LocalDateTime.now()); return result; } private void updateMetrics(TestExecutionResult result) { String testName = result.getTest().getName(); String status = result.getStatus().name().toLowerCase(); // Counter for test executions meterRegistry.counter("synthetic.test.executions", "testName", testName, "status", status ).increment(); // Response time histogram if (result.getResponseTimeMs() != null) { meterRegistry.timer("synthetic.test.response.time", "testName", testName ).record(java.time.Duration.ofMillis(result.getResponseTimeMs())); } // Gauge for success rate (you might want to calculate this differently) meterRegistry.gauge("synthetic.test.success.rate", Map.of("testName", testName), this, service -> calculateSuccessRate(testName) ); } private double calculateSuccessRate(String testName) { // Implement success rate calculation based on recent results return 95.0; // Simplified } public Map<Long, String> getRunningTests() { return runningTests.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue().isDone() ? "COMPLETED" : "RUNNING" )); } } Step 6: Scheduling Configuration
SchedulingConfig.java
package com.example.synthetic.config; import org.springframework.boot.autoconfigure.quartz.QuartzDataSource; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import javax.sql.DataSource; import java.util.concurrent.Executor; @Configuration @EnableAsync @EnableScheduling public class SchedulingConfig { @Bean("testExecutionTaskExecutor") public Executor testExecutionTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(25); executor.setThreadNamePrefix("TestExecutor-"); executor.setRejectedExecutionHandler(new ThreadPoolTaskExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } @Bean @QuartzDataSource @ConfigurationProperties(prefix = "spring.datasource") public DataSource quartzDataSource() { return DataSourceBuilder.create().build(); } } TestScheduler.java
package com.example.synthetic.scheduler; import com.example.synthetic.service.TestOrchestrationService; import org.quartz.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class TestScheduler { private static final Logger logger = LoggerFactory.getLogger(TestScheduler.class); private final TestOrchestrationService orchestrationService; public TestScheduler(TestOrchestrationService orchestrationService) { this.orchestrationService = orchestrationService; } // Run every minute to check for scheduled tests @Scheduled(cron = "0 * * * * ?") public void executeScheduledTests() { logger.debug("Checking for scheduled tests..."); orchestrationService.executeScheduledTests(); } // Health check - run every 5 minutes @Scheduled(cron = "0 */5 * * * ?") public void healthCheck() { logger.info("Synthetic monitoring health check - Running tests: {}", orchestrationService.getRunningTests().size()); } } Step 7: REST API for Test Management
MonitorTestController.java
package com.example.synthetic.controller; import com.example.synthetic.model.MonitorTest; import com.example.synthetic.model.TestExecutionResult; import com.example.synthetic.service.MonitorTestService; import com.example.synthetic.service.TestOrchestrationService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.concurrent.CompletableFuture; @RestController @RequestMapping("/api/v1/monitor-tests") public class MonitorTestController { private final MonitorTestService testService; private final TestOrchestrationService orchestrationService; public MonitorTestController(MonitorTestService testService, TestOrchestrationService orchestrationService) { this.testService = testService; this.orchestrationService = orchestrationService; } @GetMapping public ResponseEntity<Page<MonitorTest>> getTests( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { return ResponseEntity.ok(testService.findAll(PageRequest.of(page, size))); } @GetMapping("/{id}") public ResponseEntity<MonitorTest> getTest(@PathVariable Long id) { return testService.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<MonitorTest> createTest(@RequestBody MonitorTest test) { return ResponseEntity.ok(testService.save(test)); } @PutMapping("/{id}") public ResponseEntity<MonitorTest> updateTest(@PathVariable Long id, @RequestBody MonitorTest test) { test.setId(id); return ResponseEntity.ok(testService.save(test)); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteTest(@PathVariable Long id) { testService.deleteById(id); return ResponseEntity.ok().build(); } @PostMapping("/{id}/execute") public CompletableFuture<ResponseEntity<TestExecutionResult>> executeTest(@PathVariable Long id) { return orchestrationService.executeTest(id) .thenApply(ResponseEntity::ok); } @GetMapping("/{id}/results") public ResponseEntity<Page<TestExecutionResult>> getTestResults( @PathVariable Long id, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size) { return ResponseEntity.ok(testService.findResultsByTestId(id, PageRequest.of(page, size))); } } Step 8: Alerting Service
AlertService.java
package com.example.synthetic.service; import com.example.synthetic.model.TestExecutionResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.Map; @Service public class AlertService { private static final Logger logger = LoggerFactory.getLogger(AlertService.class); @Value("${synthetic.monitoring.alerts.slack-webhook-url:}") private String slackWebhookUrl; @Value("${synthetic.monitoring.alerts.pagerduty-routing-key:}") private String pagerdutyRoutingKey; private final RestTemplate restTemplate; public AlertService(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public void sendTestFailureAlert(TestExecutionResult result) { // Send to Slack if (slackWebhookUrl != null && !slackWebhookUrl.isEmpty()) { sendSlackAlert(result); } // Send to PagerDuty for critical failures if (pagerdutyRoutingKey != null && !pagerdutyRoutingKey.isEmpty() && result.getTest().getPriority().ordinal() >= 2) { // HIGH or CRITICAL sendPagerDutyAlert(result); } logger.warn("Test failure alert sent for: {} - {}", result.getTest().getName(), result.getErrorMessage()); } private void sendSlackAlert(TestExecutionResult result) { try { Map<String, Object> slackMessage = new HashMap<>(); slackMessage.put("text", "❌ Synthetic Test Failure"); Map<String, Object> attachment = new HashMap<>(); attachment.put("color", "danger"); attachment.put("title", result.getTest().getName()); attachment.put("text", result.getErrorMessage()); Map<String, Object> fields = new HashMap<>(); fields.put("title", "Details"); fields.put("value", String.format( "Test: %s\nStatus: %s\nResponse Time: %dms\nURL: %s", result.getTest().getName(), result.getStatus(), result.getResponseTimeMs(), result.getTest().getTargetUrl() )); fields.put("short", false); attachment.put("fields", new Object[]{fields}); slackMessage.put("attachments", new Object[]{attachment}); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> request = new HttpEntity<>(slackMessage, headers); restTemplate.postForEntity(slackWebhookUrl, request, String.class); } catch (Exception e) { logger.error("Failed to send Slack alert", e); } } private void sendPagerDutyAlert(TestExecutionResult result) { try { Map<String, Object> pagerDutyEvent = new HashMap<>(); pagerDutyEvent.put("routing_key", pagerdutyRoutingKey); pagerDutyEvent.put("event_action", "trigger"); Map<String, Object> payload = new HashMap<>(); payload.put("summary", String.format("Synthetic test failure: %s", result.getTest().getName())); payload.put("source", "synthetic-monitoring"); payload.put("severity", "error"); payload.put("custom_details", Map.of( "test_name", result.getTest().getName(), "error_message", result.getErrorMessage(), "response_time_ms", result.getResponseTimeMs(), "target_url", result.getTest().getTargetUrl(), "test_priority", result.getTest().getPriority().name() )); pagerDutyEvent.put("payload", payload); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> request = new HttpEntity<>(pagerDutyEvent, headers); restTemplate.postForEntity("https://events.pagerduty.com/v2/enqueue", request, String.class); } catch (Exception e) { logger.error("Failed to send PagerDuty alert", e); } } } Step 9: Sample Test Definitions
HTTP Health Check Test
{ "type": "HTTP", "name": "API Health Check", "description": "Check if main API is responding", "targetUrl": "https://api.example.com/health", "frequency": "EVERY_5_MINUTES", "priority": "HIGH", "method": "GET", "expectedStatusCode": 200, "expectedResponsePattern": "\"status\":\"UP\"", "maxResponseTimeMs": 1000, "headers": { "User-Agent": "Synthetic-Monitoring/1.0" } } Browser User Journey Test
{ "type": "BROWSER", "name": "User Login Flow", "description": "Test complete user login journey", "targetUrl": "https://app.example.com/login", "frequency": "HOURLY", "priority": "HIGH", "script": "waitForElement('#username'); waitForElement('#password'); click('#login-btn'); waitForElement('.dashboard'); assertText('.welcome-message', 'Welcome');", "maxLoadTimeMs": 10000, "takeScreenshotOnFailure": true } Benefits and Use Cases
Key Benefits:
- Proactive Monitoring - Detect issues before users are affected
- Performance Baselines - Establish and monitor performance benchmarks
- Business Transaction Monitoring - Ensure critical flows work end-to-end
- Geographic Monitoring - Test from different locations (extendable)
- Trend Analysis - Track performance degradation over time
Common Use Cases:
- API Health Checks - Verify microservices are responsive
- User Journey Validation - Ensure critical business flows work
- Third-party Integration Checks - Monitor external dependencies
- Performance Regression Detection - Catch performance degradation early
- SLA Monitoring - Ensure compliance with service level agreements
Conclusion
This comprehensive synthetic monitoring solution provides:
- Flexible test definitions for HTTP, browser, and custom validations
- Scalable execution with async processing and thread pooling
- Comprehensive alerting with Slack and PagerDuty integration
- Performance metrics with Micrometer and Prometheus
- Persistent storage for historical analysis and trending
- REST API for test management and execution
By implementing this synthetic monitoring framework, you can proactively detect issues, ensure application reliability, and maintain optimal user experience across your digital services.