Как это выглядит в реальной жизни, а не в красивых презентациях
В моем чате с Ollama есть WebSocket-соединения. Пользователи задают вопросы нейросети, она отвечает. И тут кто-то жалуется: «Тормозит». Вопрос: это сеть тормозит или сессий накопилось столько, что сервер задыхается?
Хочется простого ответа — без логов и дебаггера.
Пишем класс. Обратите внимание: всё через аннотации Actuator, никаких @RestController или @GetMapping.
java
@Component
@Endpoint(id = "websockets")
public class WebSocketActuatorEndpoint {
private final SimpUserRegistry userRegistry;
private final SimpSessionRegistry sessionRegistry;
public WebSocketActuatorEndpoint(SimpUserRegistry userRegistry,
SimpSessionRegistry sessionRegistry) {
this.userRegistry = userRegistry;
this.sessionRegistry = sessionRegistry;
}
@ReadOperation
public Map<String, Object> getStats() {
// Собираем статистику по сессиям вручную, потому что стандартные метрики не умеют
Set<SimpUser> users = userRegistry.getUsers();
int totalSessions = users.stream()
.mapToInt(user -> user.getSessions().size())
.sum();
Map<String, Integer> sessionsPerUser = new HashMap<>();
for (SimpUser user : users) {
sessionsPerUser.put(user.getName(), user.getSessions().size());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalUsers", users.size());
result.put("totalSessions", totalSessions);
result.put("sessionsPerUser", sessionsPerUser);
result.put("timestamp", Instant.now().toString());
// А вот сюрприз: если сессий больше сотни, можно добавить warning
if (totalSessions > 100) {
result.put("warning", "Многовато сессий для одного инстанса, пора горизонтально масштабироваться");
}
return result;
}
@ReadOperation
public String getHealthCheck() {
// А это уже хак: можно сделать несколько ReadOperation, если параметры разные
long sessionCount = userRegistry.getUsers().stream()
.flatMap(user -> user.getSessions().stream())
.count();
if (sessionCount > 200) {
return "CRITICAL: too many websocket sessions";
}
return "OK";
}
@WriteOperation
public void disconnectUser(String username) {
// Операция записи: выкидываем пользователя из веб-сокетов через актуатор
SimpUser user = userRegistry.getUser(username);
if (user != null) {
user.getSessions().forEach(session -> session.close(CloseStatus.GOING_AWAY));
}
}
@DeleteOperation
public void clearAllSessions() {
// Когда всё совсем плохо, сбросить все сессии разом
userRegistry.getUsers().forEach(user ->
user.getSessions().forEach(session -> session.close(CloseStatus.GOING_AWAY))
);
}
}
Теперь по адресу /actuator/websockets при GET-запросе я вижу:
json
{
"totalUsers": 42,
"totalSessions": 127,
"sessionsPerUser": {
"alexey": 1,
"pavel": 3,
"guest_123": 5
},
"timestamp": "2025-01-15T14:23:00Z",
"warning": "Многовато сессий для одного инстанса, пора горизонтально масштабироваться"
}
Без отдельного контроллера и правки security config. И без мучений, кстати.
Тонкости, о которых молчат в ютуб-туториалах
Первое. Зачем три аннотации: @ReadOperation, @WriteOperation, @DeleteOperation?
Actuator разделяет операции по принципу REST:
@ReadOperation— GET, ничего не меняет.@WriteOperation— POST, меняет состояние.@DeleteOperation— DELETE, удаляет что-то.
Можно переопределить HTTP-метод через @Selector, но обычно это не нужно.
Второе. Разные методы с одинаковой аннотацией не работают.
Вот этот код вызовет ошибку при старте:
java
@ReadOperation
public Map<String, Object> getStats() { ... }
@ReadOperation
public String getSimpleStats() { ... } // ОШИБКА! Actuator не поймет, какой вызывать
Actuator не смотрит на имена методов, только на типы параметров. Если параметров нет, считает, что это один и тот же эндпойнт.
Это можно обойти, если добавить параметр-дискриминатор:
java
@ReadOperation
public Map<String, Object> getStats(@Selector String mode) {
if ("full".equals(mode)) {
return fullStats();
}
return simpleStats();
}
И вызывать /actuator/websockets/full.
Третье. Безопасность. Свой эндпойнт наследует настройки Actuator.
Если вы настроили:
yaml
management:
endpoints:
web:
exposure:
include: "*"
Ваш эндпойнт будет доступен всем, даже если он удаляет все веб-сокеты.
Защититься можно либо через management.endpoints.web.exposure.exclude, либо через Spring Security:
java
@Endpoint(id = "websockets", enableByDefault = false) // не включается автоматически
И потом в конфиге вручную разрешаете доступ только нужным ролям.
Пример: кеш, который давно надо было посмотреть
У меня в проекте был кеш с результатами работы Ollama. Он хранил ответы на повторяющиеся вопросы — экономил деньги на GPU. Но я понятия не имел, что у него внутри.
Написал эндпойнт:
java
@Endpoint(id = "cache")
@Component
public class CacheEndpoint {
private final CacheManager cacheManager;
public CacheEndpoint(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@ReadOperation
public Map<String, Object> cacheStats() {
Cache cache = cacheManager.getCache("ollama-responses");
if (cache == null) {
return Map.of("error", "Cache not found");
}
// Через нативный доступ к библиотеке кеша (Caffeine в моем случае)
CaffeineCache caffeineCache = (CaffeineCache) cache;
com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache =
caffeineCache.getNativeCache();
return Map.of(
"size", nativeCache.estimatedSize(),
"hitRate", String.format("%.2f%%", cacheHits * 100.0 / (cacheHits + cacheMisses)),
"evictions", evictionsCount
);
}
@WriteOperation
public void evictAll() {
cacheManager.getCache("ollama-responses").clear();
}
}
Теперь я могу в любой момент дернуть /actuator/cache и увидеть: кеш на 87%, hit rate — 94%, вытеснений почти нет. Кеш работает — я спокоен.
А если кеш набивался мусором, просто отправлял POST /actuator/cache, и он очищался.
Когда пилить свой эндпойнт — святое дело, а когда — нет
Кастомный эндпойнт нужен, если:
- данные уже есть в бинах Spring, но нет готового API;
- доступ к управлению приложением «только для админа»;
- стандартные метрики не затрагивают бизнес-логику;
- лень писать полноценный REST-контроллер с документацией.
Не нужно ничего придумывать, если:
- данные можно получить через
/metricsс кастомным счетчиком (это чище); - логика требует сложной авторизации (роли, разные права);
- вы планируете отдавать эти данные на фронтенд (тогда обычный
@RestControllerпонятнее).
Что в итоге?
Свой эндпойнт Actuator — это:
-
@Component+@Endpoint(id = "..."); -
@ReadOperation,@WriteOperation,@DeleteOperation; - доступ по
/actuator/your-id; - те же права, что и у стандартных эндпойнтов;
- хороший выбор для админских «заглянуть одним глазком».
Не надо городить велосипед с отдельным портом, аутентификацией и документацией. Всё уже есть.
После того как я выкатил эндпойнт для веб-сокетов, прод перестал быть для меня черным ящиком. Я перестал гадать, сколько людей онлайн. Перестал угадывать, не пора ли добавить еще один под. Я просто открываю браузер, ввожу /actuator/websockets и вижу цифры — цивилизация!