OhDearService.java
package access.ohdear;
import access.remote.RestTemplateFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static org.springframework.http.HttpEntity.EMPTY;
@Service
@SuppressWarnings("unchecked")
public class OhDearService {
private static final Log LOG = LogFactory.getLog(OhDearService.class);
private static final int PERIOD = 60;
private static final int PERIOD_ALL = 364;
private final String baseUrl;
private RestTemplate restTemplate;
private final CacheManager cacheManager;
private static final DateTimeFormatter OHDEAR_FORMAT =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC);
private final boolean enabled;
private StatusResponse disabledResponse;
@SneakyThrows
public OhDearService(@Value("${ohdear.apiKey}") String apiToken,
@Value("${ohdear.baseUrl}") String baseUrl,
@Value("${ohdear.enabled}") boolean enabled,
ObjectMapper objectMapper,
CacheManager cacheManager) {
this.baseUrl = baseUrl;
this.enabled = enabled;
this.cacheManager = cacheManager;
if (enabled) {
restTemplate = RestTemplateFactory.buildRestTemplate(apiToken);
} else {
disabledResponse = objectMapper.readValue(
new ClassPathResource("ohdear/ohdear.json").getInputStream(),
StatusResponse.class);
}
}
private Map<String, Object> get(String url) {
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.GET, EMPTY, Map.class);
return response.getBody();
}
@Scheduled(fixedRate = 30, initialDelay = 5, timeUnit = TimeUnit.MINUTES)
public void refreshStatus() {
List.of(PERIOD, PERIOD_ALL)
.forEach(period -> {
long time = System.currentTimeMillis();
StatusResponse aggregatedStatusInternal = getAggregatedStatusInternal(period);
Objects.requireNonNull(cacheManager.getCache("status")).put(period, aggregatedStatusInternal);
LOG.info(String.format("Refreshed all ohDear stats for period %s in %s ms",
period, System.currentTimeMillis() - time));
});
}
@Cacheable(value = "status", key = "#period")
public StatusResponse getAggregatedStatus(int period) {
return getAggregatedStatusInternal(period);
}
private StatusResponse getAggregatedStatusInternal(int period) {
if (!enabled) {
return disabledResponse;
}
Map<String, Object> monitorsResponse = get(baseUrl + "/monitors");
List<Map<String, Object>> monitors = (List<Map<String, Object>>) monitorsResponse.get("data");
Map<String, List<ServiceStatus>> grouped = new HashMap<>();
monitors.forEach(monitor -> {
Long id = ((Number) monitor.get("id")).longValue();
String name = (String) monitor.get("label");
String url = (String) monitor.get("url");
String status = deriveStatus(monitor);
Double uptimePercentage = fetchUptime(id, period);
List<Incident> incidents = fetchIncidentsFromDowntime(id, period);
ServiceStatus service = new ServiceStatus(id, name, url, status, uptimePercentage, incidents);
String groupName = (String) monitor.getOrDefault("group_name", "Other");
grouped.computeIfAbsent(groupName, k -> new ArrayList<>()).add(service);
});
List<Group> groups = new ArrayList<>();
grouped.forEach((groupName, services) -> groups.add(new Group(groupName, services)));
return new StatusResponse(deriveOverallStatus(groups), Instant.now().toString(), groups);
}
private Double fetchUptime(Long id, int period) {
try {
Instant now = Instant.now();
Instant start = now.minus(period, ChronoUnit.DAYS);
String startedAt = OHDEAR_FORMAT.format(start);
String endedAt = OHDEAR_FORMAT.format(now);
String url = String.format(
"%s/monitors/%d/uptime?filter[started_at]=%s&filter[ended_at]=%s&split=day",
baseUrl,
id,
startedAt,
endedAt
);
Object raw = getRaw(url);
List<Map<String, Object>> data;
if (raw instanceof Map) {
// Case: { data: [...] }
data = (List<Map<String, Object>>) ((Map<?, ?>) raw).get("data");
} else if (raw instanceof List) {
// Case: [...]
data = (List<Map<String, Object>>) raw;
} else {
return null;
}
if (data == null || data.isEmpty()) return null;
double[] total = {0};
int[] count = {0};
data.forEach(entry -> {
Object uptime = entry.get("uptime_percentage");
if (uptime instanceof Number) {
total[0] += ((Number) uptime).doubleValue();
count[0]++;
}
});
return count[0] > 0 ? total[0] / count[0] : null;
} catch (Exception e) {
LOG.error("Exception in fetchUptime", e);
return null;
}
}
private List<Incident> fetchIncidentsFromDowntime(Long id, int period) {
try {
Instant now = Instant.now();
Instant start = now.minus(period, ChronoUnit.DAYS);
String startedAt = OHDEAR_FORMAT.format(start);
String endedAt = OHDEAR_FORMAT.format(now);
String url = String.format(
"%s/monitors/%d/downtime?filter[started_at]=%s&filter[ended_at]=%s",
baseUrl,
id,
startedAt,
endedAt
);
Map<String, Object> results = get(url);
List<Map<String, Object>> data = (List<Map<String, Object>>) results.get("data");
if (data == null) return List.of();
List<Incident> incidents = new ArrayList<>();
data.forEach(downtime -> {
String message = (String) downtime.getOrDefault("notes_markdown", downtime.getOrDefault("notes_html", "Service disruption detected"));
incidents.add(new Incident((String) downtime.get("started_at"), (String) downtime.get("ended_at"), message));
});
// sort newest first (better for UI)
incidents.sort((a, b) -> b.getStartedAt().compareTo(a.getStartedAt()));
return incidents;
} catch (Exception e) {
LOG.error("Exception in fetchIncidentsFromDowntime", e);
return List.of();
}
}
private Object getRaw(String url) {
ResponseEntity<Object> response = restTemplate.exchange(url, HttpMethod.GET, EMPTY, Object.class);
return response.getBody();
}
private String deriveStatus(Map<String, Object> monitor) {
String status = ((String) monitor.getOrDefault("status", "operational")).toLowerCase();
return switch (status) {
case "down", "degraded" -> status;
default -> "operational";
};
}
private String deriveOverallStatus(List<Group> groups) {
boolean hasDown = groups.stream().flatMap(g -> g.services().stream())
.anyMatch(s -> "down".equals(s.status()));
if (hasDown) {
return "down";
}
boolean hasDegraded = groups.stream().flatMap(g -> g.services().stream())
.anyMatch(s -> "degraded".equals(s.status()));
if (hasDegraded) {
return "degraded";
}
return "operational";
}
}