committed by
GitHub
24 changed files with 1064 additions and 27 deletions
@ -0,0 +1,34 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.data.notification; |
|||
|
|||
public record AffectedService(String name, Status status, int failureCount) { |
|||
|
|||
public enum Status { FAILING, RECOVERED, HIGH_LATENCY } |
|||
|
|||
public static AffectedService failing(String name, int failureCount) { |
|||
return new AffectedService(name, Status.FAILING, failureCount); |
|||
} |
|||
|
|||
public static AffectedService recovered(String name) { |
|||
return new AffectedService(name, Status.RECOVERED, 0); |
|||
} |
|||
|
|||
public static AffectedService highLatency(String name) { |
|||
return new AffectedService(name, Status.HIGH_LATENCY, 0); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.data.notification; |
|||
|
|||
public interface ShortNameProvider { |
|||
|
|||
String getShortName(); |
|||
|
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.notification.channels.impl; |
|||
|
|||
import com.slack.api.Slack; |
|||
import com.slack.api.SlackConfig; |
|||
import com.slack.api.methods.MethodsClient; |
|||
import com.slack.api.methods.SlackApiTextResponse; |
|||
import com.slack.api.methods.request.chat.ChatPostMessageRequest; |
|||
import com.slack.api.methods.request.chat.ChatUpdateRequest; |
|||
import com.slack.api.methods.response.chat.ChatPostMessageResponse; |
|||
import com.slack.api.methods.response.chat.ChatUpdateResponse; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
@Slf4j |
|||
public class SlackApiClient { |
|||
|
|||
private static final int DEFAULT_CALL_TIMEOUT_MS = 5000; |
|||
|
|||
private final Slack slack; |
|||
private final String botToken; |
|||
|
|||
public SlackApiClient(String botToken) { |
|||
this(botToken, DEFAULT_CALL_TIMEOUT_MS); |
|||
} |
|||
|
|||
public SlackApiClient(String botToken, int callTimeoutMs) { |
|||
this.botToken = botToken; |
|||
SlackConfig config = new SlackConfig(); |
|||
config.setHttpClientCallTimeoutMillis(callTimeoutMs); |
|||
config.setHttpClientReadTimeoutMillis(callTimeoutMs); |
|||
config.setHttpClientWriteTimeoutMillis(callTimeoutMs); |
|||
this.slack = Slack.getInstance(config); |
|||
} |
|||
|
|||
public String postMessage(String channelId, String text) { |
|||
ChatPostMessageRequest request = ChatPostMessageRequest.builder() |
|||
.channel(channelId) |
|||
.text(text) |
|||
.build(); |
|||
ChatPostMessageResponse response = sendRequest(request); |
|||
return response.getTs(); |
|||
} |
|||
|
|||
public String postThreadReply(String channelId, String threadTs, String text) { |
|||
ChatPostMessageRequest request = ChatPostMessageRequest.builder() |
|||
.channel(channelId) |
|||
.text(text) |
|||
.threadTs(threadTs) |
|||
.build(); |
|||
ChatPostMessageResponse response = sendRequest(request); |
|||
return response.getTs(); |
|||
} |
|||
|
|||
public void close() { |
|||
try { |
|||
slack.close(); |
|||
} catch (Exception e) { |
|||
log.warn("Failed to close Slack client", e); |
|||
} |
|||
} |
|||
|
|||
public void updateMessage(String channelId, String ts, String text) { |
|||
ChatUpdateRequest request = ChatUpdateRequest.builder() |
|||
.channel(channelId) |
|||
.ts(ts) |
|||
.text(text) |
|||
.build(); |
|||
MethodsClient client = slack.methods(botToken); |
|||
ChatUpdateResponse response; |
|||
try { |
|||
response = client.chatUpdate(request); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException("Failed to update Slack message: " + e.getMessage(), e); |
|||
} |
|||
checkResponse(response); |
|||
} |
|||
|
|||
private ChatPostMessageResponse sendRequest(ChatPostMessageRequest request) { |
|||
MethodsClient client = slack.methods(botToken); |
|||
ChatPostMessageResponse response; |
|||
try { |
|||
response = client.chatPostMessage(request); |
|||
} catch (Exception e) { |
|||
throw new RuntimeException("Failed to send Slack message: " + e.getMessage(), e); |
|||
} |
|||
checkResponse(response); |
|||
return response; |
|||
} |
|||
|
|||
private void checkResponse(SlackApiTextResponse response) { |
|||
if (response.isOk()) { |
|||
return; |
|||
} |
|||
String error = response.getError(); |
|||
if (error != null) { |
|||
switch (error) { |
|||
case "missing_scope" -> { |
|||
String neededScope = response.getNeeded(); |
|||
error = "bot token scope '" + neededScope + "' is needed"; |
|||
} |
|||
case "not_in_channel" -> error = "app needs to be added to the channel"; |
|||
} |
|||
} else if (response.getWarning() != null) { |
|||
error = "warning: " + response.getWarning(); |
|||
} else { |
|||
error = "unknown error"; |
|||
} |
|||
throw new RuntimeException("Slack API error: " + error); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.notification.channels.impl; |
|||
|
|||
import org.thingsboard.monitoring.notification.incident.IncidentTransport; |
|||
|
|||
public class SlackIncidentTransport implements IncidentTransport { |
|||
|
|||
private final SlackApiClient slackApiClient; |
|||
private final String channelId; |
|||
|
|||
public SlackIncidentTransport(SlackApiClient slackApiClient, String channelId) { |
|||
this.slackApiClient = slackApiClient; |
|||
this.channelId = channelId; |
|||
} |
|||
|
|||
@Override |
|||
public String postIncident(String text) { |
|||
return slackApiClient.postMessage(channelId, text); |
|||
} |
|||
|
|||
@Override |
|||
public void postThreadReply(String threadId, String text) { |
|||
slackApiClient.postThreadReply(channelId, threadId, text); |
|||
} |
|||
|
|||
@Override |
|||
public void updateIncident(String threadId, String text) { |
|||
slackApiClient.updateMessage(channelId, threadId, text); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,292 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.notification.incident; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.monitoring.data.notification.AffectedService; |
|||
|
|||
import java.time.Duration; |
|||
import java.time.Instant; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.LinkedHashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.ScheduledExecutorService; |
|||
import java.util.concurrent.ScheduledFuture; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
/** |
|||
* Thread-safety: all public entry points and scheduled callbacks are {@code synchronized} on the |
|||
* manager instance. Transport I/O is performed while the monitor is held. This is safe under the |
|||
* assumptions that (a) the transport enforces a short per-call timeout, and (b) notification |
|||
* producers are single-threaded; see the Slack client (default 5s) for the Slack-based transport. |
|||
*/ |
|||
@Slf4j |
|||
public class IncidentManager { |
|||
|
|||
private final IncidentTransport transport; |
|||
private final long resolutionTimeoutSeconds; |
|||
private final String messagePrefix; |
|||
private final boolean tagChannel; |
|||
private final ScheduledExecutorService scheduler; |
|||
|
|||
private String activeIncidentThreadId; |
|||
private ScheduledFuture<?> resolutionTask; |
|||
private ScheduledFuture<?> durationUpdateTask; |
|||
private Instant incidentStartTime; |
|||
private Instant lastAlertTime; |
|||
private final Map<String, Integer> failingServices = new LinkedHashMap<>(); |
|||
private final Map<String, Integer> recoveredServices = new LinkedHashMap<>(); |
|||
private final Set<String> highLatencyServices = new LinkedHashSet<>(); |
|||
|
|||
public IncidentManager(IncidentTransport transport, long resolutionTimeoutSeconds, |
|||
String messagePrefix, boolean tagChannel) { |
|||
this.transport = transport; |
|||
this.resolutionTimeoutSeconds = resolutionTimeoutSeconds; |
|||
this.messagePrefix = messagePrefix; |
|||
this.tagChannel = tagChannel; |
|||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { |
|||
Thread t = new Thread(r, "incident-manager"); |
|||
t.setDaemon(true); |
|||
return t; |
|||
}); |
|||
} |
|||
|
|||
public synchronized void sendAlert(String message, List<AffectedService> affectedServices) { |
|||
try { |
|||
if (activeIncidentThreadId == null) { |
|||
if (affectedServices.stream().allMatch(s -> s.status() == AffectedService.Status.RECOVERED)) { |
|||
return; |
|||
} |
|||
incidentStartTime = Instant.now(); |
|||
failingServices.clear(); |
|||
recoveredServices.clear(); |
|||
highLatencyServices.clear(); |
|||
applyAffectedServices(affectedServices); |
|||
activeIncidentThreadId = transport.postIncident(buildOngoingMessageText()); |
|||
startDurationUpdater(); |
|||
log.info("New incident created, thread id: {}", activeIncidentThreadId); |
|||
} else if (applyAffectedServices(affectedServices)) { |
|||
safeUpdateHeader(); |
|||
} |
|||
|
|||
try { |
|||
transport.postThreadReply(activeIncidentThreadId, message); |
|||
log.debug("Alert added to incident thread {}", activeIncidentThreadId); |
|||
} catch (Exception e) { |
|||
log.error("Failed to post alert to incident thread {}", activeIncidentThreadId, e); |
|||
} |
|||
} finally { |
|||
if (activeIncidentThreadId != null) { |
|||
lastAlertTime = Instant.now(); |
|||
// High latency is a warning only — it has no explicit recovery signal
|
|||
// (HighLatencyNotification fires only when something is above threshold),
|
|||
// so resolution hinges on failing services alone.
|
|||
if (failingServices.isEmpty()) { |
|||
resetResolutionTimer(); |
|||
} else { |
|||
cancelResolutionTimer(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private boolean applyAffectedServices(List<AffectedService> affectedServices) { |
|||
boolean changed = false; |
|||
Set<String> latencySnapshot = null; |
|||
for (AffectedService service : affectedServices) { |
|||
String name = service.name(); |
|||
switch (service.status()) { |
|||
case FAILING -> { |
|||
Integer prev = failingServices.put(name, service.failureCount()); |
|||
if (prev == null || prev.intValue() != service.failureCount()) { |
|||
changed = true; |
|||
} |
|||
if (recoveredServices.remove(name) != null) { |
|||
changed = true; |
|||
} |
|||
} |
|||
case RECOVERED -> { |
|||
Integer lastFailureCount = failingServices.remove(name); |
|||
if (lastFailureCount != null) { |
|||
recoveredServices.put(name, lastFailureCount); |
|||
changed = true; |
|||
} |
|||
} |
|||
case HIGH_LATENCY -> { |
|||
if (latencySnapshot == null) { |
|||
latencySnapshot = new LinkedHashSet<>(); |
|||
} |
|||
latencySnapshot.add(name); |
|||
} |
|||
} |
|||
} |
|||
// HighLatencyNotification carries the full current set of high latencies, so treat it as a
|
|||
// snapshot: replace highLatencyServices entirely. Without this, a brief spike would stay
|
|||
// yellow in the header until the incident resolves.
|
|||
if (latencySnapshot != null && !latencySnapshot.equals(highLatencyServices)) { |
|||
highLatencyServices.clear(); |
|||
highLatencyServices.addAll(latencySnapshot); |
|||
changed = true; |
|||
} |
|||
return changed; |
|||
} |
|||
|
|||
private String buildOngoingMessageText() { |
|||
StringBuilder sb = new StringBuilder(); |
|||
if (tagChannel) { |
|||
sb.append("<!channel> "); |
|||
} |
|||
if (messagePrefix != null && !messagePrefix.isEmpty()) { |
|||
sb.append("*").append(messagePrefix).append("*"); |
|||
} |
|||
sb.append(" :rotating_light:"); |
|||
Duration elapsed = Duration.between(incidentStartTime, Instant.now()); |
|||
if (elapsed.toMinutes() >= 1) { |
|||
sb.append(" (").append(formatDuration(elapsed)).append(")"); |
|||
} |
|||
if (hasAffected()) { |
|||
sb.append(" | ").append(formatAffectedServices()); |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
private boolean hasAffected() { |
|||
return !failingServices.isEmpty() || !recoveredServices.isEmpty() || !highLatencyServices.isEmpty(); |
|||
} |
|||
|
|||
private void safeUpdateHeader() { |
|||
try { |
|||
transport.updateIncident(activeIncidentThreadId, buildOngoingMessageText()); |
|||
} catch (Exception e) { |
|||
log.error("Failed to update incident message", e); |
|||
} |
|||
} |
|||
|
|||
private void resetResolutionTimer() { |
|||
cancelResolutionTimer(); |
|||
resolutionTask = scheduler.schedule(this::resolveIncident, resolutionTimeoutSeconds, TimeUnit.SECONDS); |
|||
} |
|||
|
|||
private void cancelResolutionTimer() { |
|||
if (resolutionTask != null) { |
|||
resolutionTask.cancel(false); |
|||
resolutionTask = null; |
|||
} |
|||
} |
|||
|
|||
private void startDurationUpdater() { |
|||
if (durationUpdateTask != null) { |
|||
durationUpdateTask.cancel(false); |
|||
} |
|||
durationUpdateTask = scheduler.scheduleAtFixedRate(this::updateDuration, 60, 60, TimeUnit.SECONDS); |
|||
} |
|||
|
|||
private synchronized void updateDuration() { |
|||
if (activeIncidentThreadId == null) { |
|||
return; |
|||
} |
|||
safeUpdateHeader(); |
|||
} |
|||
|
|||
private void stopDurationUpdater() { |
|||
if (durationUpdateTask != null) { |
|||
durationUpdateTask.cancel(false); |
|||
durationUpdateTask = null; |
|||
} |
|||
} |
|||
|
|||
static String formatDuration(Duration duration) { |
|||
long totalMinutes = duration.toMinutes(); |
|||
if (totalMinutes < 60) { |
|||
return totalMinutes + "m"; |
|||
} |
|||
long hours = totalMinutes / 60; |
|||
long minutes = totalMinutes % 60; |
|||
return minutes > 0 ? hours + "h" + minutes + "m" : hours + "h"; |
|||
} |
|||
|
|||
synchronized void resolveIncident() { |
|||
if (activeIncidentThreadId == null) { |
|||
return; |
|||
} |
|||
String threadId = activeIncidentThreadId; |
|||
stopDurationUpdater(); |
|||
String resolutionMessage = buildResolutionMessage(); |
|||
activeIncidentThreadId = null; |
|||
resolutionTask = null; |
|||
failingServices.clear(); |
|||
recoveredServices.clear(); |
|||
highLatencyServices.clear(); |
|||
try { |
|||
transport.updateIncident(threadId, resolutionMessage); |
|||
log.info("Incident resolved (thread was {})", threadId); |
|||
} catch (Exception e) { |
|||
log.error("Failed to send incident resolution message", e); |
|||
} |
|||
} |
|||
|
|||
private String buildResolutionMessage() { |
|||
Duration totalDuration = lastAlertTime != null |
|||
? Duration.between(incidentStartTime, lastAlertTime) |
|||
: Duration.between(incidentStartTime, Instant.now()); |
|||
StringBuilder sb = new StringBuilder(); |
|||
if (messagePrefix != null && !messagePrefix.isEmpty()) { |
|||
sb.append("*").append(messagePrefix).append("*"); |
|||
} |
|||
sb.append(" :white_check_mark:"); |
|||
sb.append(" (").append(formatDuration(totalDuration)).append(")"); |
|||
if (hasAffected()) { |
|||
sb.append(" | ").append(formatAffectedServices()).append("\n"); |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
private String formatAffectedServices() { |
|||
StringBuilder sb = new StringBuilder(); |
|||
boolean first = true; |
|||
for (Map.Entry<String, Integer> entry : failingServices.entrySet()) { |
|||
if (!first) sb.append(", "); |
|||
sb.append(":red_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); |
|||
first = false; |
|||
} |
|||
for (String name : highLatencyServices) { |
|||
if (!first) sb.append(", "); |
|||
sb.append(":large_yellow_circle: ").append(name); |
|||
first = false; |
|||
} |
|||
for (Map.Entry<String, Integer> entry : recoveredServices.entrySet()) { |
|||
if (!first) sb.append(", "); |
|||
sb.append(":large_green_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); |
|||
first = false; |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
public void shutdown() { |
|||
scheduler.shutdownNow(); |
|||
try { |
|||
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { |
|||
log.warn("Incident scheduler did not terminate in time"); |
|||
} |
|||
} catch (InterruptedException e) { |
|||
Thread.currentThread().interrupt(); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.notification.incident; |
|||
|
|||
public interface IncidentTransport { |
|||
|
|||
String postIncident(String text); |
|||
|
|||
void postThreadReply(String threadId, String text); |
|||
|
|||
void updateIncident(String threadId, String text); |
|||
|
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.data.notification; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class ServiceFailureNotificationTest { |
|||
|
|||
@Test |
|||
void stripResponseBodyRemovesNginxErrorHtml() { |
|||
String msg = "503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\": \"" |
|||
+ "<html><head><title>503 Service Temporarily Unavailable</title></head>" |
|||
+ "<body><center><h1>503 Service Temporarily Unavailable</h1></center><hr><center>nginx</center></body></html>\""; |
|||
|
|||
String sanitized = ServiceFailureNotification.stripResponseBody(msg); |
|||
|
|||
assertThat(sanitized) |
|||
.isEqualTo("503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\""); |
|||
} |
|||
|
|||
@Test |
|||
void stripResponseBodyRemovesDoctypeHtml() { |
|||
String msg = "500 Internal Server Error: \"<!DOCTYPE html><html>...</html>\""; |
|||
|
|||
String sanitized = ServiceFailureNotification.stripResponseBody(msg); |
|||
|
|||
assertThat(sanitized).isEqualTo("500 Internal Server Error"); |
|||
} |
|||
|
|||
@Test |
|||
void stripResponseBodyLeavesPlainMessagesUntouched() { |
|||
String msg = "Connection refused"; |
|||
assertThat(ServiceFailureNotification.stripResponseBody(msg)).isEqualTo(msg); |
|||
} |
|||
|
|||
@Test |
|||
void stripResponseBodyHandlesNull() { |
|||
assertThat(ServiceFailureNotification.stripResponseBody(null)).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void linkifyReplacesRequestForUrlWithSlackMrkdwnLink() { |
|||
String msg = "503 Service Temporarily Unavailable on POST request for \"https://example.com/api/auth/login\""; |
|||
|
|||
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) |
|||
.isEqualTo("503 Service Temporarily Unavailable on POST <https://example.com/api/auth/login|request>"); |
|||
} |
|||
|
|||
@Test |
|||
void linkifyReplacesRequestConnectToUrlFailed() { |
|||
String msg = "I/O error on POST request: Connect to https://example.com:443 failed: Connect timed out"; |
|||
|
|||
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) |
|||
.isEqualTo("I/O error on POST <https://example.com:443|request>: Connect timed out"); |
|||
} |
|||
|
|||
@Test |
|||
void linkifyLeavesMessagesWithoutRequestUrlUntouched() { |
|||
String msg = "Connection refused"; |
|||
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)).isEqualTo(msg); |
|||
} |
|||
|
|||
@Test |
|||
void linkifyHandlesNull() { |
|||
assertThat(ServiceFailureNotification.linkifyRequestUrl(null)).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void shortNameUsesShortNameProviderWhenAvailable() { |
|||
ShortNameProvider provider = () -> "MQTT"; |
|||
assertThat(ServiceFailureNotification.shortName(provider)).isEqualTo("MQTT"); |
|||
} |
|||
|
|||
@Test |
|||
void shortNameFallsBackToToStringForOtherKeys() { |
|||
Object key = new Object() { |
|||
@Override public String toString() { return "LOGIN"; } |
|||
}; |
|||
assertThat(ServiceFailureNotification.shortName(key)).isEqualTo("LOGIN"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,183 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* 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 org.thingsboard.monitoring.notification.incident; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.monitoring.data.notification.AffectedService; |
|||
|
|||
import java.time.Duration; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class IncidentManagerTest { |
|||
|
|||
private RecordingTransport transport; |
|||
private IncidentManager manager; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
transport = new RecordingTransport(); |
|||
manager = new IncidentManager(transport, 3600L, "tbqa", false); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
manager.shutdown(); |
|||
} |
|||
|
|||
@Test |
|||
void formatDurationRendersMinutesAndHours() { |
|||
assertThat(IncidentManager.formatDuration(Duration.ofSeconds(30))).isEqualTo("0m"); |
|||
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(5))).isEqualTo("5m"); |
|||
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(59))).isEqualTo("59m"); |
|||
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(60))).isEqualTo("1h"); |
|||
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(75))).isEqualTo("1h15m"); |
|||
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(120))).isEqualTo("2h"); |
|||
} |
|||
|
|||
@Test |
|||
void firstFailureOpensIncidentAndPostsHeaderAndReply() { |
|||
manager.sendAlert("CoAP failure message", |
|||
List.of(AffectedService.failing("CoAP", 1))); |
|||
|
|||
assertThat(transport.incidents).hasSize(1); |
|||
assertThat(transport.incidents.get(0)).contains(":rotating_light:").contains(":red_circle: CoAP (1)"); |
|||
assertThat(transport.replies).hasSize(1); |
|||
assertThat(transport.replies.get(0).text()).isEqualTo("CoAP failure message"); |
|||
} |
|||
|
|||
@Test |
|||
void isolatedRecoveryWithoutActiveIncidentIsIgnored() { |
|||
manager.sendAlert("Login is OK", |
|||
List.of(AffectedService.recovered("Login"))); |
|||
|
|||
assertThat(transport.incidents).isEmpty(); |
|||
assertThat(transport.replies).isEmpty(); |
|||
assertThat(transport.updates).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void subsequentFailureUpdatesHeaderAndPostsReply() { |
|||
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); |
|||
manager.sendAlert("CoAP repeat", List.of(AffectedService.failing("CoAP", 3))); |
|||
|
|||
assertThat(transport.incidents).hasSize(1); |
|||
assertThat(transport.replies).hasSize(2); |
|||
assertThat(transport.updates).hasSize(1); |
|||
assertThat(transport.updates.get(0).text()).contains(":red_circle: CoAP (3)"); |
|||
} |
|||
|
|||
@Test |
|||
void recoveryAfterFailureMovesServiceToGreenAndKeepsFailureCount() { |
|||
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 4))); |
|||
manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); |
|||
|
|||
assertThat(transport.updates).hasSize(1); |
|||
String updated = transport.updates.get(0).text(); |
|||
assertThat(updated).contains(":large_green_circle: CoAP (4)").doesNotContain(":red_circle:"); |
|||
} |
|||
|
|||
@Test |
|||
void highLatencyIsTrackedAsYellow() { |
|||
manager.sendAlert("high latency", |
|||
List.of(AffectedService.highLatency("logInLatency"))); |
|||
|
|||
assertThat(transport.incidents.get(0)).contains(":large_yellow_circle: logInLatency"); |
|||
} |
|||
|
|||
@Test |
|||
void repeatingSameFailureCountDoesNotTriggerRedundantUpdate() { |
|||
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 3))); |
|||
manager.sendAlert("CoAP still failing", List.of(AffectedService.failing("CoAP", 3))); |
|||
|
|||
assertThat(transport.updates).isEmpty(); |
|||
assertThat(transport.replies).hasSize(2); |
|||
} |
|||
|
|||
@Test |
|||
void fullLifecycleStartFailRecoverResolve() { |
|||
manager.sendAlert("Login failure", List.of(AffectedService.failing("Login", 1))); |
|||
manager.sendAlert("WS failure", List.of(AffectedService.failing("WS Connect", 1))); |
|||
manager.sendAlert("Login is OK", List.of(AffectedService.recovered("Login"))); |
|||
|
|||
assertThat(transport.incidents).hasSize(1); |
|||
|
|||
manager.resolveIncident(); |
|||
|
|||
assertThat(transport.updates).last() |
|||
.extracting(RecordingTransport.Message::text) |
|||
.asString() |
|||
.contains(":white_check_mark:") |
|||
.contains(":red_circle: WS Connect") |
|||
.contains(":large_green_circle: Login (1)"); |
|||
} |
|||
|
|||
@Test |
|||
void resolveWithoutActiveIncidentIsNoOp() { |
|||
manager.resolveIncident(); |
|||
assertThat(transport.updates).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void doesNotAutoResolveWhileServicesAreStillFailing() throws Exception { |
|||
manager.shutdown(); |
|||
transport = new RecordingTransport(); |
|||
manager = new IncidentManager(transport, 1L, "tbqa", false); |
|||
|
|||
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); |
|||
Thread.sleep(1500); |
|||
|
|||
assertThat(transport.updates) |
|||
.extracting(RecordingTransport.Message::text) |
|||
.noneMatch(t -> t.contains(":white_check_mark:")); |
|||
|
|||
manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); |
|||
Thread.sleep(1500); |
|||
|
|||
assertThat(transport.updates) |
|||
.extracting(RecordingTransport.Message::text) |
|||
.anyMatch(t -> t.contains(":white_check_mark:")); |
|||
} |
|||
|
|||
private static class RecordingTransport implements IncidentTransport { |
|||
private final AtomicInteger threadCounter = new AtomicInteger(); |
|||
final java.util.List<String> incidents = new java.util.ArrayList<>(); |
|||
final java.util.List<Message> replies = new java.util.ArrayList<>(); |
|||
final java.util.List<Message> updates = new java.util.ArrayList<>(); |
|||
|
|||
@Override |
|||
public String postIncident(String text) { |
|||
incidents.add(text); |
|||
return "thread-" + threadCounter.incrementAndGet(); |
|||
} |
|||
|
|||
@Override |
|||
public void postThreadReply(String threadId, String text) { |
|||
replies.add(new Message(threadId, text)); |
|||
} |
|||
|
|||
@Override |
|||
public void updateIncident(String threadId, String text) { |
|||
updates.add(new Message(threadId, text)); |
|||
} |
|||
|
|||
record Message(String threadId, String text) {} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue