Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 ee5398b2ea fix(notifications): job notices only for runs nobody was watching
"<process> failed" and "<process> finished with errors" went to whoever
started any run, including interactive runs whose result screen already
showed the outcome. The listener now notifies only for runs that skip or
fail on frontend steps (scheduled, background, API, queue runs); a run
on the process screens (FrontendStepBehavior.BREAK) sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:51:54 -05:00
james.maesandClaude Opus 5.5 58195c43ab fix(notifications): drop the "API key created" notice
It told people about an API key they had just created. API key expiry,
access-change and share notices stay. newSignIn is unchanged: the qbit
keeps no sign-in history, so the host decides when a device or address
is new; its Javadoc and the docs now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:49:54 -05:00
james.maesandClaude Opus 5.5 f9d51e67e1 fix(sink): resolve the overflow alert once the queue has drained
"Alert signals are being dropped" had no dedup key, was manual-resolve
and was never re-armed, so it stayed open forever. It now uses a stable
dedup key (the key earlier versions used implicitly, so an alert they
left open resolves too). The drain thread resolves it once the queue has
stayed below half full for a minute and re-arms it, so a later overflow
raises it again. Still ERROR and admin-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:47:23 -05:00
james.maesandClaude Opus 5.5 c106385710 feat(engine): per-type reopen window, 6 hours by default for health checks
A health blip more than 30 minutes after the last resolve created a new
alert and a second bell entry for the same problem. The reopen window
is now resolved per type: alerts_type.reopenWindowMinutes (changeset
55), else the longest matching prefix in
AlertsQBitConfig.reopenWindowMinutesByTypePrefix (default health. =
360 minutes), else reopenWindowMinutes (30). A reopen updates the
existing alert and its bell entry; flapping detection is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:44:24 -05:00
james.maesandClaude Opus 5.5 bd5efc21fd fix(notifications): a resolve no longer marks the alert's bell entry unread
A RESOLVED event reset the alert's bell recipient row to unread, so good
news re-bumped the bell. The in-app channel now updates the entry in
place (title "Resolved: ...") and keeps the read state. A subscription
whose events name RESOLVED and differ from the default set keeps the old
behavior. A reopen suppressed as a duplicate refreshes the entry's text
so it never keeps reading "Resolved". External channels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:39:55 -05:00
james.maes 3adb6dc3d6 Merge pull request #46 from QRun-IO/feature/service-principal
fix(routing): per-recipient checks via the alerts service principal
2026-10-16 13:33:27 -05:00
james.maesandClaude Opus 5.5 b89c95f749 build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:08 -05:00
james.maesandClaude Opus 5.5 86b4ca5ff4 fix(routing): per-recipient checks run via the alerts service principal
Token-less automated sessions are rejected under OAuth2, so visibility,
follower and audience checks never worked there. With the host's alerts
principal declared they run on the recipient's behalf through it.
Recording/routing bookkeeping stays system so alerting never depends on
the IdP. Undeclared: unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:30:04 -05:00
james.maes a79ca3f696 Merge pull request #45 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:01 -05:00
james.maesandClaude Opus 5.5 c70210dee0 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:32 -05:00
james.maes 56e174444a Merge pull request #44 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:44 -05:00
james.maesandClaude Opus 5.5 90e389a55c chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:02 -05:00
james.maesandClaude Opus 5.5 489663a946 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:36 -05:00
james.maes fe4732e12f Merge pull request #43 from QRun-IO/feature/runtime-fields-not-audited
fix(audit): do not record-audit engine bookkeeping updates
2026-10-15 07:48:45 -05:00
james.maesandClaude Opus 5.5 b08d41da7f fix(audit): do not record-audit engine bookkeeping updates
Monitor lease claims and run results, and alert reminder/notification
timers, changed on every scheduler pass and each wrote a FIELD audit row
(~7k alerts_monitor and ~1.8k alerts_alert audits per 6h in production).
AlertsDb.update now omits the DML audit when an update touches only the
RuntimeFields of its table; any update with a configuration or state field
is audited in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:45:58 -05:00
james.maes 4a7dd2bb71 Merge pull request #42 from QRun-IO/fix/alerts-system-session-instance
fix(alerts): asSystem builds its system session with the runtime instance
2026-10-15 07:38:26 -05:00
james.maesandClaude Opus 5.5 b638741c61 fix(alerts): asSystem builds its system session with the runtime instance
The alerts-sink drain thread (and schedulers) run engine work through
AlertsRuntime.asSystem with no QContext, so new QSystemUserSession()
read a null instance and warned "QInstance was not supplied" on every
call. AlertsSecurity.systemSession(QInstance) takes the instance; the
no-arg form delegates with the context instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:25:44 -05:00
james.maes 7f79f86222 Merge pull request #41 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:57 -05:00
james.maesandClaude Opus 5.5 69b94a13f5 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-alerts.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:23:05 -05:00
james.maes 884eab9432 Merge pull request #40 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
45 changed files with 1620 additions and 133 deletions

No files matched your search

+1
View File
@@ -21,5 +21,6 @@ jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+10
View File
@@ -4,7 +4,17 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Removed
- `AlertsNotices.apiKeyCreated`: it told people about an API key they had just created. API key expiry, access-change and share notices stay. `newSignIn` is unchanged (the qbit has no sign-in history to tell a new device or address from a known one); its Javadoc now says hosts call it only for an unfamiliar device or address.
### Fixed
- Job notifications for runs the person was watching: "<process> failed" and "<process> finished with errors" went to whoever started any run, including interactive runs whose result screen already showed it. Only runs with `FrontendStepBehavior` SKIP or FAIL (scheduled, background, API, queue) notify now.
- "Alert signals are being dropped" (`alerts.sink.overflow`) stayed open forever: it had no dedup key, was manual-resolve, and the sink never re-armed it. It now has a stable dedup key, and the drain thread resolves it once the queue has stayed below half full for a minute, so a later overflow raises it again. Still ERROR, admins only.
- Duplicate health alerts: a health check that failed again more than 30 minutes after its last resolve created a second alert and a second bell entry for the same problem. The reopen window is now per type: `alerts_type.reopenWindowMinutes` (changeset 55), else the longest matching prefix in `AlertsQBitConfig.reopenWindowMinutesByTypePrefix` (default `health.` = 360 minutes), else `reopenWindowMinutes` (30). A reopen updates the existing alert and its bell entry; flapping detection is unchanged.
- Bell noise: a resolve marked the alert's bell entry unread again. It now updates the entry in place (title "Resolved: ...") and leaves the read state alone. A subscription whose events name RESOLVED and differ from the default set (`AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS`) keeps the old behavior. External channels are unchanged.
- Under OAuth2, per-recipient checks (alert visibility for external channels, follower record access, audience sessions) never worked: they used token-less automated sessions, which OAuth2 rejects, so external sends fell back to the conservative rule. When the host declares the QQQ `alerts` service principal (`AlertsQBitConfig.withServicePrincipal`, default `alerts`), these checks run on the recipient's behalf through it: the recipient's current access intersected with the principal's. Alert recording and routing bookkeeping stay on the system session, so alerting does not depend on the identity provider being up. With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- Audit noise: every monitor run (lease claim, then run result) and every reminder check wrote a "Record was Edited" audit (about 7,000 `alerts_monitor` and 1,800 `alerts_alert` audits in 6 hours on one production host, most with no field details). Engine updates that touch only bookkeeping fields (`RuntimeFields`: monitor `claimedBy`, `claimedUntil`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastValue`, `lastError`, `lastElapsedMs`, `consecutiveFailures`, `conditionSince`; alert `timedNotificationCheckedAt`, `lastNotifiedAt`, `recordRefCount`) are written with `omitDmlAudit`. Configuration edits, raises and alert state changes are audited as before, as are UI and API edits of any field.
- Read the QBit metadata version from the Maven-filtered artifact version so release builds report their release version.
- Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget.
### Added
+3 -3
View File
@@ -72,9 +72,9 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT
- **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it.
- **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts.
- **Producers** send with `QAlerts.notify(new NotificationSignal(...))` using `com.kingsrook.qqq.backend.core.alerts` types (RC.6). Alerts a user subscribes to arrive in the bell as ALERT notifications.
- **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens.
- **Ready-made notices** in `AlertsNotices`: `newSignIn` (call it only for a device or address not seen recently), `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens.
- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link.
- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`).
- **Failed and partial jobs** notify the person who started them when the run was not interactive (scheduled, background or API runs; `notifyFailedJobs`, `jobRunLinkPattern`).
- **Links** must be app paths with one leading slash, or https URLs; backslashes, whitespace and control characters are refused.
- **Preferences:** the Notifications section combines pause, digest time and zone, alert switches, per-category in-app/email/Slack settings, and the user's own subscriptions. It uses existing tables and FIELD audits; SECURITY cannot be muted in-app. Slack controls appear when the configured per-user Slack provider is registered before Alerts is produced; SECURITY Slack settings stay editable. Uses core's released user-preferences contract in QQQ `4.1.0-RC.7`; disable with `withPreferencesSectionEnabled(false)`. See [Notification settings in Preferences](docs/NOTIFICATIONS.md#34-notification-settings-in-preferences).
- **People** see them in the Next UI bell and notifications page, and choose settings in Preferences > Notifications or the standard My Notification Settings table (security notices can't be muted).
@@ -83,7 +83,7 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with
The UI routes are published as the `alerts` extension (`1.2.0`, established prefix `/alerts`), with
[the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test.
See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff.
Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing
+10
View File
@@ -1,5 +1,15 @@
# Upgrading
## Unreleased: quieter notifications
1. **Run the changelog as usual.** Changeset 55 adds the nullable `alerts_type.reopen_window_minutes` column (guarded, additive).
2. **Behavior changes to know about.**
- A resolve no longer marks the alert's bell entry unread; the entry is updated in place ("Resolved: ..."). A subscription whose events name RESOLVED and differ from the default set keeps the old behavior. Email, Slack and webhook resolve notices are unchanged.
- "Alert signals dropped" (`alerts.sink.overflow`) now resolves itself once the queue has stayed below half full for a minute. An installed type row keeps its stored Resolve Mode label; the behavior does not depend on it.
- `AlertsNotices.apiKeyCreated` is removed. Delete the call; there is no replacement notice.
- Failed and partial job notifications are sent only for runs that were not interactive (frontend steps skipped: scheduled, background, API). A run on the process screens shows its result there instead.
- The reopen window is now per alert type: an alert type's **Reopen Window Minutes**, else `AlertsQBitConfig.withReopenWindowMinutesByTypePrefix` (default `health.` = 360 minutes), else `withReopenWindowMinutes` (default 30). A health check that blips again within 6 hours reopens the same alert and bell entry instead of creating a new one.
## Unreleased: starter defaults, setup card, table help
1. **Add the defaults changelog** after `alerts.changelog.sql` in the host's master changelog:
+4 -4
View File
@@ -33,7 +33,7 @@ system as well"
- their failed or partial jobs (processes, ETL, imports and exports they started), linked to the run;
- alerts they subscribe to;
- things assigned or shared to them (a notebook share, a record lock, a pending approval);
- security notices (new sign-in, API key created or expiring, permission change);
- security notices (new sign-in from an unfamiliar device or address, API key expiring, permission change);
- application releases and marketing notifications (authored by admins or emitted by apps, such as
"new version released" or a marketing-qbit campaign), sent to an audience.
@@ -232,10 +232,10 @@ QAlerts.notify(new NotificationSignal("Your export failed")
| Source | How | Category |
|---|---|---|
| Failed or partial jobs | The qbit's process lifecycle listener: a run that fails, or completes with error summary lines, notifies the user who started it (the session user; system runs are skipped). The link is the run's table, or `jobRunLinkPattern` with `{processName}` and `{processUUID}`. Turn it off with `notifyFailedJobs(false)`. | JOB |
| Alerts they subscribe to | The router's in-app channel writes an ALERT notification per alert (sourceKey `alert:<id>`) and a recipient row per routed user. A repeat or reopen marks it unread again. | ALERT |
| Failed or partial jobs | The qbit's process lifecycle listener: a run that fails, or completes with error summary lines, notifies the user who started it (the session user; system runs are skipped). Only runs nobody is watching notify (scheduled, background, API: frontend steps skipped); an interactive run on the process screens already shows its result. The link is the run's table, or `jobRunLinkPattern` with `{processName}` and `{processUUID}`. Turn it off with `notifyFailedJobs(false)`. | JOB |
| Alerts they subscribe to | The router's in-app channel writes an ALERT notification per alert (sourceKey `alert:<id>`) and a recipient row per routed user. A raise, reopen, severity increase, reminder or escalation marks it unread again. A resolve only updates it in place (title "Resolved: ...", read state unchanged), unless the subscription chose resolve notices on purpose (its events name RESOLVED and differ from the default set). | ALERT |
| Shares, record locks, approvals | Producers call `QAlerts.notify(... user(id))`. The first wiring is the SQL Workbench notebook share in kof22. | SHARE |
| Security notices | `AlertsSecurityNotices` (new sign-in, API key created, API key expiring, permission change). Core has no authentication events today, so hosts call it from their authentication customizer. A core hook is follow-up work (section 12, Q5). Security is never mutable in-app. | SECURITY |
| Security notices | `AlertsSecurityNotices` (new sign-in, API key expiring, permission change). Core has no authentication events today, so hosts call it from their authentication customizer; the host decides whether a sign-in is from a new device or address, since the qbit keeps no sign-in history. Creating an API key sends nothing: the person who created it already knows. A core hook is follow-up work (section 12, Q5). Security is never mutable in-app. | SECURITY |
| Releases and marketing | Admin-authored notifications, or producers with an audience | RELEASE, MARKETING |
## 7. Delivery
+18 -5
View File
@@ -1,7 +1,7 @@
# Alerts UI API and webhook migration
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.1.0`, with the established `/alerts` prefix.
The qbit targets QQQ **4.1.0-RC.15**, UI API **1.6.0**. `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.2.0`, with the established `/alerts` prefix.
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
to enforce caller permissions, recipient ownership and alert visibility.
@@ -20,11 +20,21 @@ discovers the `alerts` extension so clients need not infer installation from an
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
All existing query parameters, selector precedence, paging defaults and clamps, status
codes, JSON content-type requirements, and response keys remain. IDs refer to recipient
codes, JSON content-type requirements, and success response keys remain. IDs refer to recipient
rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies
only to read/dismiss. Items in a summary omit `body`; page items and banners include it.
Nullable fields remain serialized as JSON null.
## UI API extension changes
UI API extension alerts 1.2.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds,
standard error responses with BasicErrorResponseV1, and extension tags. The six existing
operationIds are preserved. Errors retain their HTTP statuses and now include `error`,
`code`, `message`, and `traceId` (with `fieldErrors` when applicable). `AlertsErrorV1`
remains in components with a deprecation description for compatibility. The core upgrade
also retires the expired Slack shim: capable providers now receive notification
idempotency keys; providers without that capability keep recipient-row deduplication.
## Next handoff: additive payload fields
- Summary adds nullable `preferencesSection`: the user Preferences section name (`notifications`) or null when not registered. Clients can use `/qqq/v1/preferences/{name}` for notification settings.
@@ -127,12 +137,15 @@ The supplied Next `main` checkout still has no alerts client; the
## Verification
Use the Maven wrapper with the released QQQ 4.1.0-RC.7 artifacts already installed locally:
Use the Maven wrapper offline against the locally installed migration build:
```sh
./mvnw -o -q verify
./mvnw -o verify -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(with `-o` for an offline build). Normal tests compare the fragment without rewriting it.
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
The Enforcer switch skips only the release-dependency rule for the local SNAPSHOT;
omit it and the version override when building against the released RC.12 BOM.
+4 -2
View File
@@ -160,6 +160,7 @@
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-alerts.properties</include>
</includes>
</resource>
<resource>
@@ -167,6 +168,7 @@
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-alerts.properties</exclude>
</excludes>
</resource>
</resources>
@@ -258,14 +260,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -21,6 +21,7 @@ import java.io.Serializable;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryMode;
@@ -112,7 +113,7 @@ public final class AlertsPreferences
.withValue("userId", userId)
.withValue("scopeType", ScopeType.ALL.name())
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("events", AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS)
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
.withValue("enabled", true)
@@ -18,7 +18,9 @@ package com.kingsrook.qbits.alerts;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
@@ -77,9 +79,13 @@ public class AlertsQBitConfig implements QBitConfig
public static final String APP_NAME = "alerts";
/** Alert types named with this prefix are health checks (6-hour reopen window by default). */
public static final String HEALTH_TYPE_PREFIX = "health.";
private Boolean legacyAlertmanagerPath = false;
private String backendName;
private String servicePrincipal = "alerts";
private String schedulerName;
private String messagingProviderName;
private String fromAddress;
@@ -87,6 +93,8 @@ public class AlertsQBitConfig implements QBitConfig
private String publicBaseUrl;
private Integer reopenWindowMinutes = 30;
private Map<String, Integer> reopenWindowMinutesByTypePrefix = new LinkedHashMap<>(Map.of(HEALTH_TYPE_PREFIX, 360));
private Integer flapThreshold = 5;
private Integer flapWindowMinutes = 60;
private Integer maxOpenPerMonitor = 50;
@@ -162,6 +170,14 @@ public class AlertsQBitConfig implements QBitConfig
{
assertCondition(value != null && value > 0, "Alerts QBit timing and limit settings must be positive", errors);
}
if(reopenWindowMinutesByTypePrefix != null)
{
for(Map.Entry<String, Integer> entry : reopenWindowMinutesByTypePrefix.entrySet())
{
assertCondition(StringUtils.hasContent(entry.getKey()) && entry.getValue() != null && entry.getValue() > 0,
"Alerts QBit reopen windows by type prefix need a prefix and a positive number of minutes", errors);
}
}
}
@@ -423,7 +439,8 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** Fluent setter for reopenWindowMinutes (a raise this soon after a resolve
** reopens the same alert; default 30)
** reopens the same alert; default 30, for types that no prefix in
** reopenWindowMinutesByTypePrefix matches and that set no window of their own)
*******************************************************************************/
public AlertsQBitConfig withReopenWindowMinutes(Integer reopenWindowMinutes)
{
@@ -433,6 +450,71 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for reopenWindowMinutesByTypePrefix
*******************************************************************************/
public Map<String, Integer> getReopenWindowMinutesByTypePrefix()
{
return (this.reopenWindowMinutesByTypePrefix);
}
/*******************************************************************************
** Fluent setter for reopenWindowMinutesByTypePrefix: default reopen windows
** for alert types whose names start with a prefix (the longest matching
** prefix wins). Default: health.* types reopen within 360 minutes, so a
** brief health blip hours later reopens the same alert. A type's own
** reopenWindowMinutes (alerts_type) wins over this.
*******************************************************************************/
public AlertsQBitConfig withReopenWindowMinutesByTypePrefix(Map<String, Integer> reopenWindowMinutesByTypePrefix)
{
this.reopenWindowMinutesByTypePrefix = reopenWindowMinutesByTypePrefix;
return (this);
}
/*******************************************************************************
** Set the default reopen window for alert types whose names start with a
** prefix.
*******************************************************************************/
public AlertsQBitConfig withReopenWindowMinutesForTypePrefix(String prefix, Integer minutes)
{
if(this.reopenWindowMinutesByTypePrefix == null)
{
this.reopenWindowMinutesByTypePrefix = new LinkedHashMap<>();
}
this.reopenWindowMinutesByTypePrefix.put(prefix, minutes);
return (this);
}
/*******************************************************************************
** The default reopen window for an alert type: the longest matching
** prefix's, else reopenWindowMinutes.
*******************************************************************************/
public int reopenWindowMinutesFor(String alertTypeName)
{
String longest = null;
Integer minutes = reopenWindowMinutes;
if(alertTypeName != null && reopenWindowMinutesByTypePrefix != null)
{
for(Map.Entry<String, Integer> entry : reopenWindowMinutesByTypePrefix.entrySet())
{
if(entry.getKey() != null && entry.getValue() != null && alertTypeName.startsWith(entry.getKey()) && (longest == null || entry.getKey().length() > longest.length()))
{
longest = entry.getKey();
minutes = entry.getValue();
}
}
}
return (minutes == null ? 30 : minutes);
}
/*******************************************************************************
** Getter for flapThreshold
*******************************************************************************/
@@ -893,4 +975,30 @@ public class AlertsQBitConfig implements QBitConfig
this.legacyAlertmanagerPath = legacyAlertmanagerPath;
return (this);
}
/*******************************************************************************
** Getter for servicePrincipal: when the host declares this QQQ service
** principal, per-recipient checks (alert visibility, follower record access,
** audience) run on the recipient's behalf through it - their current access
** intersected with the principal's - instead of a token-less automated
** session (rejected under OAuth2). Alert recording and routing bookkeeping
** stay on the system session so alerting never depends on the identity
** provider being up.
*******************************************************************************/
public String getServicePrincipal()
{
return (this.servicePrincipal);
}
/*******************************************************************************
** Fluent setter for servicePrincipal (default alerts).
*******************************************************************************/
public AlertsQBitConfig withServicePrincipal(String servicePrincipal)
{
this.servicePrincipal = servicePrincipal;
return (this);
}
}
@@ -17,8 +17,11 @@
package com.kingsrook.qbits.alerts;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.engine.AlertsProcessListener;
import com.kingsrook.qbits.alerts.engine.AlertsSinkAdapter;
@@ -66,7 +69,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-alerts";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
@@ -75,6 +78,34 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = AlertsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-alerts.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-alerts.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or invalid version in qbit-alerts.properties; build with Maven resource filtering enabled"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-alerts.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -266,9 +297,9 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
.withGroupName("alerts")
.withDefaultSeverity(AlertSeverity.ERROR)
.withVisibilityPermission(AlertsQBitConfig.PERMISSION_ADMIN)
.withResolveMode(ResolveMode.MANUAL)
.withDescription("The in-memory queue of producer signals was full, so some signals were not recorded.")
.withHowToFix("Find the producer that floods (see Noisiest Alert Types), and check the database is keeping up. Resolve this alert when it has stopped."),
.withResolveMode(ResolveMode.PRODUCER)
.withDescription("The in-memory queue of producer signals was full, so some signals were not recorded. It resolves itself once the queue has stayed below half full for a minute.")
.withHowToFix("Find the producer that floods (see Noisiest Alert Types), and check the database is keeping up."),
new QAlertTypeMetaData()
.withName(QbitAlertsSink.FLOOD_TYPE)
.withLabel("Producer flooding alerts")
@@ -144,7 +144,7 @@ public class AlertsRuntime
public <R> R asSystem(SystemWork<R> work) throws QException
{
Object[] result = new Object[1];
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession()), () ->
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession(qInstance)), () ->
{
result[0] = work.run();
});
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
@@ -34,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -278,7 +282,19 @@ public final class AlertsSecurity
*******************************************************************************/
public static QSystemUserSession systemSession()
{
QSystemUserSession session = new QSystemUserSession();
return (systemSession(QContext.getQInstance()));
}
/*******************************************************************************
** A system-user session for this instance, holding the alerts all-access
** keys. Background threads (the alerts sink, schedulers) have no QContext
** yet, so they must pass the instance in.
*******************************************************************************/
public static QSystemUserSession systemSession(QInstance qInstance)
{
QSystemUserSession session = new QSystemUserSession(qInstance);
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
@@ -295,4 +311,25 @@ public final class AlertsSecurity
{
return (QContext.getQSession() instanceof QSystemUserSession);
}
/*******************************************************************************
** A session for a recipient: on their behalf through the alerts service
** principal when the host declares it (QQQ service identities), else the
** authentication module's automated session (previous behavior). Null when
** neither can be made - callers fail closed.
*******************************************************************************/
public static QSession recipientSession(QInstance qInstance, String principalName, String userId) throws QException
{
if(qInstance == null || qInstance.getAuthentication() == null)
{
return (null);
}
if(StringUtils.hasContent(principalName) && ServiceIdentities.isDeclared(qInstance, principalName))
{
return (ServiceIdentities.sessionOnBehalfOf(qInstance, principalName, userId));
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
}
}
@@ -23,6 +23,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.Method;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
@@ -79,10 +80,10 @@ abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -300,7 +300,7 @@ public final class AlertsSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.2.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
}
@@ -52,7 +52,7 @@ public final class AlertsUiApi
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.1.0").withDescription("Alerts inbox and record panel")
.withSemanticVersion("1.2.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
@@ -66,8 +66,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -94,15 +92,28 @@ public class AlertRouter
private static final int DUPLICATE_WINDOW_MINUTES = 10;
/** The events a new subscription gets unless someone chooses others. */
public static final String DEFAULT_SUBSCRIPTION_EVENTS = "RAISED,ESCALATED,REMINDER,RESOLVED";
private final AlertsRuntime runtime;
/*******************************************************************************
** One destination for one notification.
** One destination for one notification. explicitResolve: the subscription
** behind it chose resolve notices on purpose (see explicitlyWantsResolved),
** so a resolve brings the alert back to unread in that person's bell.
*******************************************************************************/
record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode)
record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode, boolean explicitResolve)
{
/***************************************************************************
** A destination that is not from a subscription with explicit resolve
** notices.
***************************************************************************/
Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode)
{
this(userId, channel, subscriptionId, minSeverity, preference, deliveryMode, false);
}
}
@@ -339,6 +350,14 @@ public class AlertRouter
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
notification.setValue("suppressedReason", reason.name());
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
if(inApp && reason == SuppressedReason.DUPLICATE)
{
///////////////////////////////////////////////////////////////////
// not a new notice, but keep the bell entry's text in step (a //
// quick reopen must not leave it reading "Resolved") //
///////////////////////////////////////////////////////////////////
runtime.getNotifications().deliverAlert(destination.userId(), alert, now, false);
}
}
else if(digest)
{
@@ -351,12 +370,21 @@ public class AlertRouter
}
else if(inApp)
{
//////////////////////////////////////////////////////////////////////////
// a resolve is good news, not something to act on: it updates the //
// alert's bell entry in place without marking it unread, unless the //
// person's subscription asked for resolve notices on purpose //
//////////////////////////////////////////////////////////////////////////
boolean quiet = kind == DeliveryKind.RESOLVED && !destination.explicitResolve();
notification.setValue("status", DeliveryStatus.SENT.name());
notification.setValue("sentAt", now);
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
queued++;
runtime.getNotifications().deliverAlert(destination.userId(), alert, now, !quiet);
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")), logPair("quiet", quiet));
if(!quiet)
{
queued++;
}
}
else
{
@@ -473,7 +501,8 @@ public class AlertRouter
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
for(QRecord channel : userChannels(subscription, preference))
{
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference, subscription.getValueString("deliveryMode")));
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference,
subscription.getValueString("deliveryMode"), explicitlyWantsResolved(subscription.getValueString("events"))));
}
}
}
@@ -783,7 +812,41 @@ public class AlertRouter
/*******************************************************************************
** Whether a subscription's events list includes this kind (empty: all).
** Whether a subscription asked for resolve notices on purpose: its events
** name RESOLVED and are not the default set (an empty list means "all
** kinds", which is not a choice either).
*******************************************************************************/
static boolean explicitlyWantsResolved(String events)
{
if(!StringUtils.hasContent(events) || !includesKind(events, DeliveryKind.RESOLVED))
{
return (false);
}
return (!eventSet(events).equals(eventSet(DEFAULT_SUBSCRIPTION_EVENTS)));
}
/*******************************************************************************
** A subscription's events as an upper-case set.
*******************************************************************************/
private static Set<String> eventSet(String events)
{
Set<String> set = new java.util.TreeSet<>();
for(String event : events.split("[,\\s]+"))
{
if(!event.isBlank())
{
set.add(event.trim().toUpperCase(java.util.Locale.ROOT));
}
}
return (set);
}
/*******************************************************************************
** Whether a subscription's events include a kind (empty: all kinds).
*******************************************************************************/
private static boolean includesKind(String events, DeliveryKind kind)
{
@@ -882,18 +945,12 @@ public class AlertRouter
/*******************************************************************************
** A session for a user, from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId) throws QException
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
@@ -40,7 +40,8 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Small helpers over QQQ's table actions, for the engine. They run in
** whatever context the caller set up (the engine uses the system user), so
** customizers, audits and ESB listeners apply as for any other write.
** customizers, audits and ESB listeners apply as for any other write, except
** that bookkeeping-only updates (RuntimeFields) are not record-audited.
*******************************************************************************/
public final class AlertsDb
{
@@ -114,11 +115,14 @@ public final class AlertsDb
/*******************************************************************************
** Update one row (it must carry its id); throws on an error.
** Update one row (it must carry its id); throws on an error. An update of
** engine bookkeeping fields only (RuntimeFields) is not record-audited.
*******************************************************************************/
public static QRecord update(String tableName, QRecord record) throws QException
{
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
UpdateInput input = new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction())
.withOmitDmlAudit(RuntimeFields.onlyRuntime(tableName, record));
List<QRecord> updated = UpdateAction.executeForRecords(input);
QRecord result = updated.get(0);
throwIfErrors(tableName, result);
return (result);
@@ -63,8 +63,10 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** - Raise on an open (type, dedupKey) updates that alert: lastRaisedAt,
** occurrences, record refs. A severity increase records SEVERITY_CHANGED,
** unsnoozes and notifies again; a decrease only records it.
** - Raise on a key resolved less than reopenWindowMinutes ago reopens the
** same alert; later, a new alert is created (history stays on the old one).
** - Raise on a key resolved within the type's reopen window (its own
** reopenWindowMinutes, else 6 hours for health.* types and 30 minutes for
** others, both configurable) reopens the same alert and its bell entry;
** later, a new alert is created (history stays on the old one).
** - Flapping: flapThreshold open/resolve changes within flapWindowMinutes
** set flapping (one notice; further open/resolve notifications are
** suppressed until it is stable for the window).
@@ -155,7 +157,7 @@ public class AlertsEngine
}
else
{
QRecord recent = recentlyResolved(signal.getAlertTypeName(), dedupKey, now);
QRecord recent = recentlyResolved(type, signal.getAlertTypeName(), dedupKey, now);
if(recent != null)
{
alert = reopen(recent, type, signal, severity, monitorId, now, null, "Raised again within the reopen window");
@@ -680,9 +682,9 @@ public class AlertsEngine
/*******************************************************************************
** The most recent alert for (type, key) resolved within the reopen window.
*******************************************************************************/
private QRecord recentlyResolved(String alertTypeName, String dedupKey, Instant now) throws QException
private QRecord recentlyResolved(QRecord type, String alertTypeName, String dedupKey, Instant now) throws QException
{
Instant cutoff = now.minus(runtime.getConfig().getReopenWindowMinutes(), ChronoUnit.MINUTES);
Instant cutoff = now.minus(reopenWindowMinutes(type, alertTypeName), ChronoUnit.MINUTES);
return (AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeName", alertTypeName)
.withCriteria("dedupKey", QCriteriaOperator.EQUALS, dedupKey)
.withCriteria("status", QCriteriaOperator.EQUALS, Status.RESOLVED.name())
@@ -692,6 +694,23 @@ public class AlertsEngine
/*******************************************************************************
** How long after a resolve a raise of the same (type, key) reopens the
** alert: the type's own reopenWindowMinutes, else the configured default
** for its name (6 hours for health.* types, 30 minutes otherwise).
*******************************************************************************/
int reopenWindowMinutes(QRecord type, String alertTypeName)
{
Integer perType = type == null ? null : type.getValueInteger("reopenWindowMinutes");
if(perType != null && perType > 0)
{
return (perType);
}
return (runtime.getConfig().reopenWindowMinutesFor(alertTypeName));
}
/*******************************************************************************
** Record a REPEATED entry at most once an hour per alert.
*******************************************************************************/
@@ -60,8 +60,12 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** cached for a minute).
** - Job notifications (docs/NOTIFICATIONS.md 6.1): when a run started by a
** person fails, or completes with error summary lines (a partial run),
** that person gets a JOB notification linked to the run. System runs and
** the qbit's own processes are skipped. Off with notifyFailedJobs(false).
** that person gets a JOB notification linked to the run. Only runs nobody
** is watching notify: scheduled, background, API and other runs that skip
** frontend steps. An interactive run (FrontendStepBehavior.BREAK, the
** process screens) already shows its result, so it sends nothing. System
** runs and the qbit's own processes are skipped. Off with
** notifyFailedJobs(false).
*******************************************************************************/
public class AlertsProcessListener implements ProcessLifecycleListenerInterface
{
@@ -230,10 +234,14 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
/*******************************************************************************
** Send a JOB notification to the person who started the run (nothing for
** system runs or sessions without a user).
** interactive runs, system runs or sessions without a user).
*******************************************************************************/
private static void notifyStarter(AlertsRuntime runtime, RunProcessInput input, NotificationSeverity severity, String titleFormat, String body)
{
if(isInteractive(input))
{
return;
}
QSession session = QContext.getQSession();
String userId = session == null || session instanceof QSystemUserSession ? null : AlertsSecurity.userId(session);
if(userId == null)
@@ -256,6 +264,18 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
/*******************************************************************************
** Whether a person is watching the run on the process screens: they stop
** at frontend steps (BREAK) and end on the result screen. Scheduled,
** background, API and queue runs skip or fail on frontend steps.
*******************************************************************************/
public static boolean isInteractive(RunProcessInput input)
{
return (input != null && input.getFrontendStepBehavior() == RunProcessInput.FrontendStepBehavior.BREAK);
}
/*******************************************************************************
** Where a job notification links: the configured run link pattern, else
** the process's table, else nowhere.
@@ -41,7 +41,9 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** The qbit's sink (design section 3.2): producers' signals go into a bounded
** in-memory queue drained by a virtual thread into the alerts tables, so a
** producer never waits on the database. If the queue overflows, the signal
** is dropped and counted and alerts.sink.overflow is raised once; past 1,000
** is dropped and counted and alerts.sink.overflow is raised once; when the
** queue has stayed below half full for a minute the drain thread resolves
** it, so a later overflow raises it again; past 1,000
** signals a minute for one type, signals are counted, not stored, and
** alerts.producer.flood is raised (section 7.5).
**
@@ -55,10 +57,14 @@ public class QbitAlertsSink implements AlertSinkInterface
public static final String OVERFLOW_TYPE = "alerts.sink.overflow";
public static final String FLOOD_TYPE = "alerts.producer.flood";
/** The overflow alert's dedup key: the key earlier versions used implicitly, so an alert they left open resolves too. */
public static final String OVERFLOW_DEDUP_KEY = OVERFLOW_TYPE;
static final int QUEUE_CAPACITY = 10_000;
static final int FLOOD_PER_MINUTE = 1_000;
static final long POLL_MILLIS = 200;
static final long STOP_WAIT_MILLIS = 10_000;
static final long OVERFLOW_CLEAR_MILLIS = 60_000;
private final AlertsRuntime runtime;
private final BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
@@ -67,6 +73,7 @@ public class QbitAlertsSink implements AlertSinkInterface
private final AtomicLong dropped = new AtomicLong();
private final Map<String, AtomicInteger> perMinute = new ConcurrentHashMap<>();
private volatile long minute = -1;
private volatile long drainedSinceMillis = -1;
private volatile Thread drainThread;
private volatile boolean stopping = false;
@@ -237,14 +244,87 @@ public class QbitAlertsSink implements AlertSinkInterface
if(!queue.offer(runnable))
{
dropped.incrementAndGet();
if(overflowRaised.compareAndSet(false, true))
{
LOG.warn("The alerts queue is full; dropping signals", logPair("capacity", QUEUE_CAPACITY));
Thread.ofVirtual().start(() -> safely(() -> runtime.getEngine().raise(new AlertSignal(OVERFLOW_TYPE)
.withSeverity(AlertSeverity.ERROR)
.withTitle("Alert signals are being dropped")
.withSummary("The alerts queue was full, so some signals were not recorded."))));
}
overflowed();
}
}
/*******************************************************************************
** A signal was dropped because the queue is full: raise the overflow alert
** (once, until the queue has drained).
*******************************************************************************/
void overflowed()
{
drainedSinceMillis = -1;
if(overflowRaised.compareAndSet(false, true))
{
LOG.warn("The alerts queue is full; dropping signals", logPair("capacity", QUEUE_CAPACITY));
runMetaAlert(() -> runtime.getEngine().raise(new AlertSignal(OVERFLOW_TYPE)
.withDedupKey(OVERFLOW_DEDUP_KEY)
.withSeverity(AlertSeverity.ERROR)
.withTitle("Alert signals are being dropped")
.withSummary("The alerts queue was full, so some signals were not recorded.")));
}
}
/*******************************************************************************
** Called by the drain thread: once the overflow alert is raised and the
** queue has stayed below half full for OVERFLOW_CLEAR_MILLIS, resolve the
** alert and re-arm it, so a later overflow raises it again.
*******************************************************************************/
void checkOverflowCleared()
{
if(!overflowRaised.get())
{
return;
}
long now = runtime.now().toEpochMilli();
if(queue.size() >= QUEUE_CAPACITY / 2)
{
drainedSinceMillis = -1;
return;
}
if(drainedSinceMillis < 0)
{
drainedSinceMillis = now;
return;
}
if(now - drainedSinceMillis >= OVERFLOW_CLEAR_MILLIS && overflowRaised.compareAndSet(true, false))
{
drainedSinceMillis = -1;
LOG.info("The alerts queue has drained; resolving the overflow alert", logPair("dropped", dropped.get()));
runMetaAlert(() -> runtime.getEngine().resolve(OVERFLOW_TYPE, OVERFLOW_DEDUP_KEY, Resolution.PRODUCER, null,
"The queue stayed below half full for " + (OVERFLOW_CLEAR_MILLIS / 1000) + " seconds."));
}
}
/*******************************************************************************
** Whether the overflow alert is raised and not yet cleared.
*******************************************************************************/
boolean isOverflowRaised()
{
return (overflowRaised.get());
}
/*******************************************************************************
** Record a meta-alert off the producer's thread (on it with synchronousSink).
*******************************************************************************/
private void runMetaAlert(EngineWork work)
{
if(Boolean.TRUE.equals(runtime.getConfig().getSynchronousSink()))
{
safely(work);
}
else
{
Thread.ofVirtual().start(() -> safely(work));
}
}
@@ -296,6 +376,7 @@ public class QbitAlertsSink implements AlertSinkInterface
{
work.run();
}
checkOverflowCleared();
}
catch(InterruptedException e)
{
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts.engine;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Engine bookkeeping columns: evaluation leases, run results and notification
** timers that change on every scheduler pass. An update that touches only
** these (plus the primary key and modifyDate) is not record-audited; the core
** FIELD audit would otherwise write an audit row per monitor run and per
** reminder check. Any update that also changes a configuration or alert-state
** field (status, severity, acknowledgement, assignment, a monitor's settings)
** is audited in full, as before. Run history lives in the run fields
** themselves; alert raises, transitions and notifications have their own
** event and delivery tables.
*******************************************************************************/
public final class RuntimeFields
{
private static final Set<String> ALWAYS = Set.of("id", "modifyDate");
private static final Map<String, Set<String>> BY_TABLE = Map.of(
AlertsMonitor.TABLE_NAME, Set.of("claimedBy", "claimedUntil", "lastRunAt", "nextRunAt", "lastStatus", "lastValue", "lastError",
"lastElapsedMs", "consecutiveFailures", "conditionSince"),
AlertsAlert.TABLE_NAME, Set.of("timedNotificationCheckedAt", "lastNotifiedAt", "recordRefCount"));
/*******************************************************************************
** Constants only.
*******************************************************************************/
private RuntimeFields()
{
}
/*******************************************************************************
** The bookkeeping fields of a table (empty when it has none).
*******************************************************************************/
public static Set<String> of(String tableName)
{
return (BY_TABLE.getOrDefault(tableName, Set.of()));
}
/*******************************************************************************
** Whether an update record carries nothing but bookkeeping fields.
*******************************************************************************/
public static boolean onlyRuntime(String tableName, QRecord update)
{
Set<String> runtime = of(tableName);
return (!runtime.isEmpty() && update.getValues().keySet().stream().allMatch(field -> runtime.contains(field) || ALWAYS.contains(field)));
}
}
@@ -27,6 +27,7 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
import com.kingsrook.qbits.alerts.customizers.AlertsSubscriptionCustomizer;
import com.kingsrook.qbits.alerts.customizers.AlertsWriteGuardCustomizer;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
@@ -158,7 +159,7 @@ public final class AlertsTableMetaDataHelper
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "groupId", "sourceName", "openCount")))
.withSection(new QFieldSection("about", "About", new QIcon("info"), Tier.T2, List.of("description", "howToFix", "subjectTableName", "visibilityPermission")))
.withSection(new QFieldSection("tuning", "Tuning", new QIcon("tune"), Tier.T2, List.of("enabled", "notify", "defaultSeverity", "severityOverride", "resolveMode",
"quietPeriodMinutes", "reminderIntervalMinutes", "escalationPolicyId", "externalDetailCeiling")))
"quietPeriodMinutes", "reopenWindowMinutes", "reminderIntervalMinutes", "escalationPolicyId", "externalDetailCeiling")))
.withSection(widgetSection(JOIN_TYPE_MONITOR, "Monitors", "monitor_heart"))
.withSection(widgetSection(JOIN_TYPE_ALERT, "Alerts", "notifications_active"))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("installed", "lastRaisedAt", "createDate", "modifyDate")));
@@ -262,7 +263,7 @@ public final class AlertsTableMetaDataHelper
table.getField("scopeType").setDefaultValue(AlertsPossibleValues.ScopeType.ALL.name());
table.getField("minSeverity").setDefaultValue("WARNING");
table.getField("deliveryMode").setDefaultValue(AlertsPossibleValues.DeliveryMode.IMMEDIATE.name());
table.getField("events").setDefaultValue("RAISED,ESCALATED,REMINDER,RESOLVED");
table.getField("events").setDefaultValue(AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS);
table.getField("enabled").setDefaultValue(true);
table.getField("userId").setIsEditable(false);
return (customize(table, AlertsSubscriptionCustomizer.class));
@@ -90,6 +90,9 @@ public class AlertsType extends QRecordEntity
@QField()
private Integer reminderIntervalMinutes;
@QField()
private Integer reopenWindowMinutes;
@QField(isEditable = false)
private Instant lastRaisedAt;
@@ -684,6 +687,37 @@ public class AlertsType extends QRecordEntity
/*******************************************************************************
** Getter for reopenWindowMinutes
*******************************************************************************/
public Integer getReopenWindowMinutes()
{
return (this.reopenWindowMinutes);
}
/*******************************************************************************
** Setter for reopenWindowMinutes
*******************************************************************************/
public void setReopenWindowMinutes(Integer reopenWindowMinutes)
{
this.reopenWindowMinutes = reopenWindowMinutes;
}
/*******************************************************************************
** Fluent setter for reopenWindowMinutes
*******************************************************************************/
public AlertsType withReopenWindowMinutes(Integer reopenWindowMinutes)
{
this.reopenWindowMinutes = reopenWindowMinutes;
return (this);
}
/*******************************************************************************
** Getter for lastRaisedAt
*******************************************************************************/
@@ -26,6 +26,7 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -33,8 +34,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -218,20 +217,14 @@ public class AlertsAudienceResolver
/*******************************************************************************
** A session for a user from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId)
{
try
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
catch(Exception e)
{
@@ -37,7 +37,8 @@ import com.kingsrook.qqq.backend.core.alerts.QAlerts;
** qbit installed core uses its logging sink.
**
** Core has no sign-in or API key events yet, so a host calls the security
** notices from its authentication customizer or API key screens.
** notices from its authentication customizer or API key screens. There is
** no notice for creating an API key: the person who created it just did it.
*******************************************************************************/
public final class AlertsNotices
{
@@ -56,7 +57,8 @@ public final class AlertsNotices
/*******************************************************************************
** A sign-in from a new device or place (where: e.g. "Firefox on Windows,
** near Columbus, OH").
** near Columbus, OH"). Call it only for a device or address not seen for
** this user recently: the host has the sign-in history, this qbit does not.
*******************************************************************************/
public static void newSignIn(String userId, String where)
{
@@ -67,18 +69,6 @@ public final class AlertsNotices
/*******************************************************************************
** An API key was created for the user.
*******************************************************************************/
public static void apiKeyCreated(String userId, String keyLabel)
{
send(new NotificationSignal("API key created: " + keyLabel)
.withBody("A new API key was created on your account. If you didn't create it, revoke it now.")
.withSeverity(NotificationSeverity.INFO), userId, null);
}
/*******************************************************************************
** An API key expires soon (one notification per key, refreshed on repeat).
*******************************************************************************/
@@ -34,6 +34,7 @@ import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
@@ -73,6 +74,9 @@ public class NotificationService
public static final String STATUS_SENT = "SENT";
public static final String STATUS_RETRACTED = "RETRACTED";
/** Put before a resolved alert's title in the bell. */
public static final String RESOLVED_PREFIX = "Resolved: ";
static final int BATCH = 500;
private final AlertsRuntime runtime;
@@ -332,11 +336,30 @@ public class NotificationService
** back to unread on every delivery. Call as the system user.
*******************************************************************************/
public void deliverAlert(String userId, QRecord alert, Instant now) throws QException
{
deliverAlert(userId, alert, now, true);
}
/*******************************************************************************
** Put an alert in a user's bell. With markUnread false (a resolve) the
** alert's existing bell entry is only updated in place, its title marked
** "Resolved: ", and the user's read state is left as it was; nothing is
** added when the alert was never in the bell. Returns whether the user's
** bell entry was written. Call as the system user.
*******************************************************************************/
public boolean deliverAlert(String userId, QRecord alert, Instant now, boolean markUnread) throws QException
{
Integer alertId = alert.getValueInteger("id");
String sourceKey = "alert:" + alertId;
QRecord values = new QRecord()
.withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200))
String title = alert.getValueString("title");
if(!markUnread && AlertsPossibleValues.Status.RESOLVED.name().equals(alert.getValueString("status")))
{
title = RESOLVED_PREFIX + (title == null ? "" : title);
}
QRecord values = new QRecord()
.withValue("title", StringUtils.safeTruncate(title, 200))
.withValue("body", alert.getValueString("summary"))
.withValue("severity", severityOf(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
.withValue("category", NotificationCategory.ALERT.name())
@@ -352,6 +375,10 @@ public class NotificationService
Integer notificationId;
if(notification == null)
{
if(!markUnread)
{
return (false);
}
notificationId = AlertsDb.insert(AlertsNotification.TABLE_NAME, values
.withValue("audienceType", NotificationAudience.Type.USERS.name())
.withValue("status", STATUS_SENT)
@@ -367,6 +394,10 @@ public class NotificationService
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)
.withCriteria("userId", QCriteriaOperator.EQUALS, userId));
if(!markUnread)
{
return (recipient != null);
}
if(recipient == null)
{
AlertsDb.insert(AlertsNotificationRecipient.TABLE_NAME, newRecipient(notificationId, userId, now));
@@ -378,6 +409,7 @@ public class NotificationService
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id"))
.withValue("deliveredAt", now).withValue("readAt", null).withValue("dismissedAt", null));
}
return (true);
}
@@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -232,9 +233,8 @@ public class NotificationSlackSender
** when the provider says the user can't be reached (no Slack connection),
** with no retry; FAILED for anything else.
**
** STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6 SendMessageInput has no
** idempotency key (notify:<notificationId>:<userId>:slack), so the
** recipient row's slackStatus is what prevents a resend.
** Pass the notification idempotency key when the provider supports it.
** The recipient row's slackStatus also prevents a resend.
*******************************************************************************/
private SlackStatus sendExternal(String userId, String subject, List<QRecord> items, Integer notificationId)
{
@@ -258,6 +258,7 @@ public class NotificationSlackSender
{
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName(runtime.getConfig().getSlackProviderName())
.withIdempotencyKey(SendMessageAction.capabilities(runtime.getConfig().getSlackProviderName()).contains(MessagingProviderCapability.IDEMPOTENCY) ? key : null)
.withTo(new Party().withAddress("user:" + userId))
.withSubject(subject)
.withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing()))));
@@ -25,6 +25,7 @@ import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
@@ -414,7 +415,7 @@ public final class AlertsActionSteps
.withValue("scopeValue", scopeValue)
.withValue("minSeverity", StringUtils.hasContent(input.getValueString(AlertsAction.FIELD_MIN_SEVERITY)) ? input.getValueString(AlertsAction.FIELD_MIN_SEVERITY) : "WARNING")
.withValue("deliveryMode", StringUtils.hasContent(input.getValueString(AlertsAction.FIELD_DELIVERY_MODE)) ? input.getValueString(AlertsAction.FIELD_DELIVERY_MODE) : "IMMEDIATE")
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("events", AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS)
.withValue("enabled", true)));
throwIfErrors(inserted);
return (true);
@@ -775,3 +775,9 @@ INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_i
DELETE FROM alerts_escalation_step c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_channel_id;
-- changeset qbit-alerts:55
-- comment: Per-type reopen window: a raise this many minutes after a resolve reopens the same alert. Empty uses the qbit's default for the type.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'reopen_window_minutes'
ALTER TABLE alerts_type ADD COLUMN reopen_window_minutes INTEGER;
+5
View File
@@ -321,6 +321,10 @@ entries:
summary: Minutes between reminders while an alert remains open and unacknowledged.
content: |
Set zero to disable reminders. If blank, Critical alerts default to 60 minutes; other severities have no reminders. Acknowledgement, snooze, resolution and suppression stop timed notifications.
- key: table:alerts_type;field:reopenWindowMinutes
summary: Minutes after a resolve during which the same problem reopens its alert instead of starting a new one.
content: |
Empty uses the default: 6 hours for health checks (types named health.*) and 30 minutes for the rest, set by the application. A reopen updates the existing alert and its bell entry, so a short blip does not show up twice.
- key: table:alerts_type;field:quietPeriodMinutes
summary: Minutes with no new reports before an alert resolves itself; used when Resolve Mode is Quiet Period.
content: |
@@ -419,6 +423,7 @@ entries:
summary: Which kinds of notification to send, as a comma-separated list; empty means all.
content: |
Default is {{default:alerts_subscription.events}}. Raised also covers a reopened alert, a severity increase and an alert starting to flap. Reminders and escalations are sent immediately, including for digest subscriptions.
In the bell, a resolve only marks the alert's entry as resolved and leaves it read or unread as it was. To have resolves come back as unread, choose a list that names Resolved and differs from the default, such as Raised and Resolved.
- key: table:alerts_subscription;field:scopeValue
summary: What the scope matches on; its meaning depends on Scope Type.
content: |
+230 -26
View File
@@ -16,7 +16,7 @@ components:
type: "string"
type: "object"
AlertsErrorV1:
description: "Framework error response."
description: "Deprecated since 1.2.0: errors use BasicErrorResponseV1."
properties:
error:
nullable: true
@@ -212,17 +212,89 @@ components:
type: "integer"
type: "array"
type: "object"
BasicErrorResponseV1:
description: "The error body of every UI API operation (core and extensions)."
properties:
error:
description: "The error message (the same as message; kept for clients of\
\ UI API 1.0 to 1.5)."
type: "string"
code:
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
\ Extensions may add codes; treat an unknown code by the HTTP status.\
\ Since 1.6.0."
type: "string"
message:
description: "A message to show the user. Since 1.6.0."
type: "string"
fieldErrors:
description: "Errors about particular request fields, when known. Since\
\ 1.6.0."
items:
properties:
field:
description: "The field (or parameter) name."
type: "string"
message:
description: "The error about that field."
type: "string"
code:
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
\ PATTERN."
type: "string"
type: "object"
type: "array"
traceId:
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
\ response header); quote it when reporting a problem. Since 1.6.0."
type: "string"
type: "object"
securitySchemes:
sessionUuidCookie:
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
bearerAuth:
bearerFormat: "JWT or session UUID"
description: "`Authorization: Bearer <token>`, where the token is either\n-\
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
\ to the app's client,\n or to its native client (`values.nativeClientId`\
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
\ as signing in on the web. When it expires (401), refresh it at the provider\
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
\ which the server keeps refreshed."
scheme: "bearer"
type: "http"
oauth2:
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
\ token\nas `Authorization: Bearer`."
flows:
authorizationCode:
authorizationUrl: "/authorize"
scopes:
email: "Email address"
offline_access: "A refresh token, to renew the access token without signing\
\ in again"
openid: "OpenID Connect sign-in"
profile: "Name, username and groups (permissions come from the groups)"
tokenUrl: "/token"
type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.1.0"
version: "1.2.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
@@ -234,25 +306,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -260,10 +332,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxSummaryV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read the bell summary"
tags:
- "alerts"
/qqq/v1/alerts/inbox:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -304,25 +397,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -330,10 +423,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxPageV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Page the notification inbox"
tags:
- "alerts"
/qqq/v1/alerts/inbox/read:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -349,25 +463,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -375,10 +489,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications read"
tags:
- "alerts"
/qqq/v1/alerts/inbox/unread:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -394,25 +529,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -420,10 +555,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications unread"
tags:
- "alerts"
/qqq/v1/alerts/inbox/dismiss:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -439,25 +595,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -465,10 +621,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Dismiss own notifications"
tags:
- "alerts"
/qqq/v1/alerts/record-panel:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -491,25 +668,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -517,7 +694,34 @@ paths:
schema:
$ref: "#/components/schemas/AlertsRecordPanelV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read visible alerts for a record"
tags:
- "alerts"
servers:
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
url: "/"
tags:
- description: "Operations of the alerts extension."
name: "alerts"
@@ -0,0 +1 @@
version=${project.version}
@@ -51,12 +51,12 @@ class AlertsApiContractTest extends BaseAlertsTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts") || name.equals("BasicErrorResponseV1"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
assertThat(extension.getSemanticVersion()).isEqualTo("1.2.0");
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "preferencesSection", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
@@ -224,7 +224,12 @@ class AlertsApiHttpTest extends BaseAlertsTest
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
assertThat(invalidJson.statusCode()).isEqualTo(400);
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
Map<String, Object> error = JsonUtils.toObject(invalidJson.body(), Map.class);
assertThat(error).containsKeys("error", "code", "message", "traceId")
.containsEntry("message", "The request body is not valid JSON.");
assertThat(error.get("error")).isEqualTo(error.get("message"));
assertThat(error.get("code")).isNotNull();
assertThat((String) error.get("traceId")).isNotBlank();
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
@@ -64,6 +64,8 @@ class AlertsQBitProducerTest extends BaseAlertsTest
void testProduce()
{
assertThat(qInstance.getQBits()).hasSize(1);
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank().isEqualTo(AlertsQBitProducer.VERSION);
assertThat(qInstance.getQBits().values().iterator().next().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(qInstance.getQBits().values().iterator().next().getArtifactId()).isEqualTo(AlertsQBitProducer.ARTIFACT_ID);
assertThat(AlertsRuntime.find(qInstance)).isSameAs(runtime);
assertThat(AlertsRuntime.find(new QInstance())).isNull();
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QDelegatedSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Per-recipient checks run on the recipient's behalf through the alerts
** service principal when the host declares it; otherwise unchanged.
*******************************************************************************/
class AlertsServicePrincipalTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void recipientSessionsGoThroughTheDeclaredPrincipal() throws Exception
{
QSession before = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(before).isNotInstanceOf(QDelegatedSession.class);
assertThat(before.getUser().getIdReference()).isEqualTo("alice");
ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("alerts").withMayActOnBehalfOfUsers(true));
QSession after = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(after).isInstanceOf(QDelegatedSession.class);
assertThat(after.getUser().getIdReference()).isEqualTo("alice");
assertThat(ServiceIdentities.principalOf(after)).isEqualTo("alerts");
assertThat(ServiceIdentities.attributionName(after)).endsWith("via service:alerts");
}
}
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Background threads (the alerts sink, schedulers) run engine work through
** asSystem with no QContext of their own; the system session must be built
** from the runtime instance, not from the (empty) context.
*******************************************************************************/
class AlertsSystemSessionTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void asSystemWithoutContextUsesTheRuntimeInstance() throws Exception
{
QContext.clear();
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
try
{
Boolean systemUser = runtime.asSystem(() -> QContext.getQInstance() != null && AlertsSecurity.isSystemUser());
assertThat(systemUser).isTrue();
assertThat(collectingLogger.getCollectedMessages())
.noneMatch(m -> m.getMessage().contains("QInstance was not supplied"));
}
finally
{
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
}
}
}
@@ -158,7 +158,7 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-alerts");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(bundle.getVersion()).as("the help bundle carries the artifact and producer version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(AlertsQBitProducer.VERSION);
});
HelpBundles.syncOnStartup(qInstance);
}
@@ -0,0 +1,313 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts.engine;
import java.time.Duration;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Notifications people should see or act on: a resolve updates the bell
** entry without marking it unread (unless the subscription asked for
** resolve notices on purpose), a reopen reuses the same bell entry, and
** health types reopen within six hours instead of 30 minutes.
*******************************************************************************/
class QuietNotificationsTest extends BaseAlertsTest
{
static final String TYPE = "test.widget.broken";
static final String HEALTH = "health.database.degraded";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("Widget broken").withGroupName("widgets").withDefaultSeverity(AlertSeverity.ERROR))
.withAlertType(new QAlertTypeMetaData().withName(HEALTH).withLabel("Database degraded").withGroupName("health").withDefaultSeverity(AlertSeverity.ERROR));
}
/*******************************************************************************
** A resolve keeps the bell entry read, marks it resolved, and adds nothing.
*******************************************************************************/
@Test
void testResolveDoesNotMarkTheBellUnread() throws Exception
{
subscribe(ADMIN, AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS);
QRecord alert = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q1").withTitle("Widget q1 is broken"));
Integer id = alert.getValueInteger("id");
QRecord recipient = recipient(ADMIN);
assertThat(recipient.getValue("readAt")).isNull();
markRead(recipient);
clock.advance(Duration.ofMinutes(5));
runtime.getEngine().resolve(TYPE, "q1", Resolution.PRODUCER, null, "fixed");
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement()
.matches(n -> (NotificationService.RESOLVED_PREFIX + "Widget q1 is broken").equals(n.getValueString("title")));
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(1);
assertThat(recipient(ADMIN).getValue("readAt")).isNotNull();
////////////////////////////////////////////////////////////////////
// a reopen is news again: same bell entry, unread, title restored //
// (past the router's 10-minute duplicate window) //
////////////////////////////////////////////////////////////////////
clock.advance(Duration.ofMinutes(5));
ageDeliveries();
QRecord reopened = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q1").withTitle("Widget q1 is broken"));
assertThat(reopened.getValueInteger("id")).isEqualTo(id);
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "Widget q1 is broken".equals(n.getValueString("title")));
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(1);
assertThat(recipient(ADMIN).getValue("readAt")).isNull();
}
/*******************************************************************************
** An unread entry stays unread through a resolve, and a resolve never adds
** an entry for someone whose bell did not have the alert.
*******************************************************************************/
@Test
void testResolveLeavesUnreadAndAddsNothing() throws Exception
{
subscribe(ADMIN, "");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q2").withTitle("Widget q2 is broken"));
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", MANAGER)
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("events", AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS)
.withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true)));
runtime.getEngine().resolve(TYPE, "q2", Resolution.PRODUCER, null, null);
assertThat(recipient(ADMIN).getValue("readAt")).isNull();
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(r -> r.getValueString("userId")).containsExactly(ADMIN);
}
/*******************************************************************************
** A subscription that chose resolve notices on purpose keeps the old
** behavior: the resolve brings the entry back to unread.
*******************************************************************************/
@Test
void testExplicitResolveSubscriptionMarksUnread() throws Exception
{
subscribe(ADMIN, "RAISED,RESOLVED");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q3").withTitle("Widget q3 is broken"));
markRead(recipient(ADMIN));
runtime.getEngine().resolve(TYPE, "q3", Resolution.PRODUCER, null, null);
assertThat(recipient(ADMIN).getValue("readAt")).isNull();
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "Widget q3 is broken".equals(n.getValueString("title")));
}
/*******************************************************************************
** A reopen inside the duplicate window is not a new notice, but the bell
** entry no longer reads "Resolved".
*******************************************************************************/
@Test
void testQuickReopenRestoresTheTitle() throws Exception
{
subscribe(ADMIN, AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS);
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q4").withTitle("Widget q4 is broken"));
markRead(recipient(ADMIN));
runtime.getEngine().resolve(TYPE, "q4", Resolution.PRODUCER, null, null);
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("q4").withTitle("Widget q4 is broken"));
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "Widget q4 is broken".equals(n.getValueString("title")));
assertThat(recipient(ADMIN).getValue("readAt")).isNotNull();
}
/*******************************************************************************
** A health blip hours after the last one reopens the same alert and bell
** entry instead of adding a second "Database is degraded".
*******************************************************************************/
@Test
void testHealthBlipReopensWithinSixHours() throws Exception
{
subscribe(ADMIN, AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS);
QRecord first = runtime.getEngine().raise(new AlertSignal(HEALTH).withDedupKey("db1").withTitle("Database db1 is degraded"));
runtime.getEngine().resolve(HEALTH, "db1", Resolution.PRODUCER, null, null);
markRead(recipient(ADMIN));
clock.advance(Duration.ofHours(2));
ageDeliveries();
QRecord again = runtime.getEngine().raise(new AlertSignal(HEALTH).withDedupKey("db1").withTitle("Database db1 is degraded"));
assertThat(again.getValue("id")).isEqualTo(first.getValue("id"));
assertThat(again.getValueInteger("reopenCount")).isEqualTo(1);
assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(1);
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "Database db1 is degraded".equals(n.getValueString("title")));
assertThat(recipient(ADMIN).getValue("readAt")).isNull();
////////////////////////////////////////////////////////
// past six hours it is a new problem: a new alert //
////////////////////////////////////////////////////////
runtime.getEngine().resolve(HEALTH, "db1", Resolution.PRODUCER, null, null);
clock.advance(Duration.ofHours(7));
QRecord fresh = runtime.getEngine().raise(new AlertSignal(HEALTH).withDedupKey("db1").withTitle("Database db1 is degraded"));
assertThat(fresh.getValue("id")).isNotEqualTo(first.getValue("id"));
//////////////////////////////////////////////////////////////
// other types keep the 30-minute window //
//////////////////////////////////////////////////////////////
QRecord widget = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("w9"));
runtime.getEngine().resolve(TYPE, "w9", Resolution.PRODUCER, null, null);
clock.advance(Duration.ofHours(2));
assertThat(runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("w9")).getValue("id")).isNotEqualTo(widget.getValue("id"));
}
/*******************************************************************************
** A type's own reopen window wins over the default for its name.
*******************************************************************************/
@Test
void testPerTypeReopenWindow() throws Exception
{
QRecord widget = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("w8"));
runtime.asSystem(() -> AlertsDb.update(AlertsType.TABLE_NAME, new QRecord().withValue("id", widget.getValue("alertTypeId")).withValue("reopenWindowMinutes", 240)));
runtime.getEngine().resolve(TYPE, "w8", Resolution.PRODUCER, null, null);
clock.advance(Duration.ofHours(3));
assertThat(runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("w8")).getValue("id")).isEqualTo(widget.getValue("id"));
QRecord health = runtime.getEngine().raise(new AlertSignal(HEALTH).withDedupKey("db2"));
runtime.asSystem(() -> AlertsDb.update(AlertsType.TABLE_NAME, new QRecord().withValue("id", health.getValue("alertTypeId")).withValue("reopenWindowMinutes", 10)));
runtime.getEngine().resolve(HEALTH, "db2", Resolution.PRODUCER, null, null);
clock.advance(Duration.ofMinutes(20));
assertThat(runtime.getEngine().raise(new AlertSignal(HEALTH).withDedupKey("db2")).getValue("id")).isNotEqualTo(health.getValue("id"));
}
/*******************************************************************************
** Default windows by name prefix: the longest match wins.
*******************************************************************************/
@Test
void testReopenWindowByTypePrefix()
{
AlertsQBitConfig defaults = new AlertsQBitConfig();
assertThat(defaults.reopenWindowMinutesFor("health.database.degraded")).isEqualTo(360);
assertThat(defaults.reopenWindowMinutesFor("test.widget.broken")).isEqualTo(30);
assertThat(defaults.reopenWindowMinutesFor(null)).isEqualTo(30);
AlertsQBitConfig custom = new AlertsQBitConfig().withReopenWindowMinutes(15).withReopenWindowMinutesForTypePrefix("health.database.", 60);
assertThat(custom.reopenWindowMinutesFor("health.database.degraded")).isEqualTo(60);
assertThat(custom.reopenWindowMinutesFor("health.queue.slow")).isEqualTo(360);
assertThat(custom.reopenWindowMinutesFor("other")).isEqualTo(15);
assertThat(new AlertsQBitConfig().withReopenWindowMinutesByTypePrefix(null).reopenWindowMinutesFor("health.x")).isEqualTo(30);
List<String> errors = new java.util.ArrayList<>();
new AlertsQBitConfig().withBackendName(BACKEND_NAME).withReopenWindowMinutesForTypePrefix("health.", 0).validate(qInstance, errors);
assertThat(errors).anyMatch(e -> e.contains("reopen windows by type prefix"));
}
/*******************************************************************************
** Which event lists count as an explicit choice of resolve notices.
*******************************************************************************/
@Test
void testExplicitlyWantsResolved()
{
assertThat(AlertRouter.explicitlyWantsResolved(null)).isFalse();
assertThat(AlertRouter.explicitlyWantsResolved("")).isFalse();
assertThat(AlertRouter.explicitlyWantsResolved(AlertRouter.DEFAULT_SUBSCRIPTION_EVENTS)).isFalse();
assertThat(AlertRouter.explicitlyWantsResolved("resolved, reminder ,ESCALATED,raised")).isFalse();
assertThat(AlertRouter.explicitlyWantsResolved("RAISED,ESCALATED")).isFalse();
assertThat(AlertRouter.explicitlyWantsResolved("RAISED,RESOLVED")).isTrue();
assertThat(AlertRouter.explicitlyWantsResolved("RESOLVED")).isTrue();
}
/*******************************************************************************
**
*******************************************************************************/
private void subscribe(String userId, String events) throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", userId)
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("events", events)
.withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true)));
}
/*******************************************************************************
**
*******************************************************************************/
private QRecord recipient(String userId) throws QException
{
List<QRecord> rows = where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId));
assertThat(rows).hasSize(1);
return (rows.get(0));
}
/*******************************************************************************
** Move past the router's duplicate window. Its check reads createDate,
** which is wall-clock time, so the earlier deliveries are cleared instead.
*******************************************************************************/
private void ageDeliveries() throws QException
{
runtime.asSystem(() -> new DeleteAction().execute(new DeleteInput(AlertsDelivery.TABLE_NAME)
.withQueryFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IS_NOT_BLANK)))));
}
/*******************************************************************************
**
*******************************************************************************/
private void markRead(QRecord recipient) throws QException
{
runtime.asSystem(() -> AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id")).withValue("readAt", clock.instant())));
}
}
@@ -0,0 +1,166 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts.engine;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Engine bookkeeping writes (monitor leases and run results, alert reminder
** and notification timers) create no audit rows; configuration edits and
** alert state changes still do.
*******************************************************************************/
class RuntimeFieldsAuditTest extends BaseAlertsTest
{
static final String TYPE = "test.widget.broken";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("Widget broken").withGroupName("widgets").withSubjectTableName(WIDGET).withDefaultSeverity(AlertSeverity.WARNING));
}
/*******************************************************************************
** Core audit tables on the test backend.
*******************************************************************************/
@BeforeEach
void auditTables() throws Exception
{
new AuditsMetaDataProvider().defineAll(qInstance, BACKEND_NAME, null);
new QInstanceEnricher(qInstance).enrich();
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
{
for(String table : List.of("auditDetail", "audit", "auditUser", "auditTable"))
{
statement.execute("DROP TABLE IF EXISTS " + table);
}
statement.execute("CREATE TABLE auditUser (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE auditTable (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), label VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditTableId INTEGER, auditUserId INTEGER, recordId INTEGER, message VARCHAR(250), timestamp TIMESTAMP)");
statement.execute("CREATE TABLE auditDetail (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditId BIGINT, message VARCHAR(250), fieldName VARCHAR(100), oldValue VARCHAR(250), newValue VARCHAR(250))");
}
}
/*******************************************************************************
** Edited-record audits for one table.
*******************************************************************************/
private List<QRecord> edits(String tableName) throws Exception
{
List<QRecord> tables = where("auditTable", AlertsDb.eq("name", tableName));
if(tables.isEmpty())
{
return (List.of());
}
return (where("audit", AlertsDb.eq("auditTableId", tables.get(0).getValue("id")).withCriteria("message", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "Record was Edited")));
}
/*******************************************************************************
** Changed field names of one table's edit audits.
*******************************************************************************/
private List<String> editedFields(String tableName) throws Exception
{
List<Object> ids = edits(tableName).stream().map(r -> r.getValue("id")).map(Object.class::cast).toList();
return (all("auditDetail").stream().filter(d -> ids.contains(d.getValue("auditId"))).map(d -> d.getValueString("fieldName")).toList());
}
/*******************************************************************************
** The classifier: bookkeeping only, or not.
*******************************************************************************/
@Test
void classifiesBookkeepingOnlyUpdates()
{
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("claimedBy", "x").withValue("claimedUntil", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("lastRunAt", Instant.now()).withValue("enabled", false))).isFalse();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("timedNotificationCheckedAt", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("status", "ACKNOWLEDGED"))).isFalse();
assertThat(RuntimeFields.onlyRuntime(WIDGET, new QRecord().withValue("id", 1).withValue("checkedAt", Instant.now()))).isFalse();
}
/*******************************************************************************
** Evaluations and reminder checks write no monitor or alert edit audits;
** an admin edit and an acknowledgement do.
*******************************************************************************/
@Test
void evaluationsAreNotAuditedButEditsAndTransitionsAre() throws Exception
{
insertWidgets("BROKEN", "OK");
runtime.getTypeSync().syncIfNeeded();
QRecord type = runtime.asSystem(() -> AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", TYPE)));
Integer monitorId = runtime.asSystem(() -> AlertsDb.insert(AlertsMonitor.TABLE_NAME, new QRecord()
.withValue("name", "Broken widgets").withValue("alertTypeId", type.getValue("id")).withValue("kind", "TABLE_QUERY").withValue("enabled", true)
.withValue("definition", JsonUtils.toJson(Map.of("tableName", WIDGET, "filter", Map.of("criteria", List.of(Map.of("fieldName", "status", "operator", "EQUALS", "values", List.of("BROKEN")))))))
.withValue("perRecord", false).withValue("scheduleSeconds", 60).withValue("forMinutes", 0).withValue("consecutiveFailures", 0)).getValueInteger("id"));
for(int run = 0; run < 3; run++)
{
runtime.getEvaluator().evaluateDue();
new EscalationAndReminder(runtime).runDue();
clock.advance(java.time.Duration.ofMinutes(2));
}
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsMonitor.TABLE_NAME, monitorId)).getValueInstant("lastRunAt")).isNotNull();
assertThat(edits(AlertsMonitor.TABLE_NAME)).as("monitor runs are bookkeeping").isEmpty();
assertThat(editedFields(AlertsAlert.TABLE_NAME)).as("reminder checks and ref counts are bookkeeping")
.doesNotContain("timedNotificationCheckedAt", "recordRefCount", "lastNotifiedAt");
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("name", "Broken widgets (renamed)")));
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("enabled", false).withValue("nextRunAt", null)));
assertThat(edits(AlertsMonitor.TABLE_NAME)).hasSize(2);
assertThat(editedFields(AlertsMonitor.TABLE_NAME)).contains("name", "enabled");
QRecord alert = where(AlertsAlert.TABLE_NAME, AlertsDb.eq("monitorId", monitorId)).get(0);
runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
assertThat(editedFields(AlertsAlert.TABLE_NAME)).contains("status");
}
}
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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.kingsrook.qbits.alerts.engine;
import java.time.Duration;
import java.util.List;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The sink's overflow alert: raised once with a stable key, resolved by the
** drain thread once the queue has stayed below half full for a minute, and
** raised again by a later overflow.
*******************************************************************************/
class SinkOverflowTest extends BaseAlertsTest
{
/*******************************************************************************
** Raise once, clear after a sustained drain, raise again.
*******************************************************************************/
@Test
void testOverflowRaisesOnceAndClears() throws Exception
{
QbitAlertsSink sink = runtime.getSink();
sink.checkOverflowCleared();
assertThat(overflowAlerts()).isEmpty();
sink.overflowed();
sink.overflowed();
List<QRecord> alerts = overflowAlerts();
assertThat(alerts).hasSize(1);
QRecord alert = alerts.get(0);
assertThat(alert.getValueString("dedupKey")).isEqualTo(QbitAlertsSink.OVERFLOW_DEDUP_KEY);
assertThat(alert.getValueString("severity")).isEqualTo("ERROR");
assertThat(alert.getValueString("status")).isEqualTo(Status.OPEN.name());
assertThat(sink.isOverflowRaised()).isTrue();
//////////////////////////////////////////////////////////////////
// the drain must be sustained: a new overflow restarts the wait //
//////////////////////////////////////////////////////////////////
sink.checkOverflowCleared();
clock.advance(Duration.ofSeconds(45));
sink.overflowed();
sink.checkOverflowCleared();
clock.advance(Duration.ofSeconds(45));
sink.checkOverflowCleared();
assertThat(overflowAlerts().get(0).getValueString("status")).isEqualTo(Status.OPEN.name());
clock.advance(Duration.ofSeconds(20));
sink.checkOverflowCleared();
assertThat(overflowAlerts().get(0).getValueString("status")).isEqualTo(Status.RESOLVED.name());
assertThat(sink.isOverflowRaised()).isFalse();
////////////////////////////////////////////
// a later overflow raises it again //
////////////////////////////////////////////
clock.advance(Duration.ofHours(2));
sink.overflowed();
assertThat(overflowAlerts()).filteredOn(a -> Status.OPEN.name().equals(a.getValueString("status"))).hasSize(1);
assertThat(sink.isOverflowRaised()).isTrue();
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> overflowAlerts() throws Exception
{
return (where(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeName", QbitAlertsSink.OVERFLOW_TYPE)));
}
}
@@ -124,6 +124,35 @@ class JobNotificationsTest extends BaseAlertsTest
/*******************************************************************************
** A run the person watches on the process screens (BREAK) already shows
** its result, so it sends nothing.
*******************************************************************************/
@Test
void testInteractiveRunNotifiesNobody() throws Exception
{
TestSteps.fail = true;
loginAs("viewer-1");
try
{
new RunProcessAction().execute(new RunProcessInput().withProcessName("nightlyThing").withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK));
}
catch(Exception e)
{
////////////////////////////////////////
// the run's own failure is expected //
////////////////////////////////////////
}
assertThat(runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME))).isEmpty();
assertThat(AlertsProcessListener.isInteractive(null)).isFalse();
assertThat(AlertsProcessListener.isInteractive(new RunProcessInput())).isTrue();
assertThat(AlertsProcessListener.isInteractive(new RunProcessInput().withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP))).isFalse();
assertThat(AlertsProcessListener.isInteractive(new RunProcessInput().withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.FAIL))).isFalse();
}
/*******************************************************************************
** Turned off, nothing is sent.
*******************************************************************************/
@@ -434,19 +434,19 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
emailSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO");
loginAs("viewer-1");
AlertsNotices.newSignIn("viewer-1", "Firefox on Windows");
AlertsNotices.apiKeyCreated("viewer-1", "ci-bot");
AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z"));
AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z"));
AlertsNotices.permissionChanged("viewer-1", "You were added to the Editors role");
AlertsNotices.shared("viewer-1", "Sam shared a notebook with you", AlertLink.toPath("/app/widget", "Open"), "qbit-sql-workbench");
List<QRecord> notifications = runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME));
assertThat(notifications).hasSize(5);
assertThat(notifications).filteredOn(n -> "SECURITY".equals(n.getValueString("category"))).hasSize(4);
assertThat(notifications).hasSize(4);
assertThat(notifications).filteredOn(n -> "SECURITY".equals(n.getValueString("category"))).hasSize(3);
assertThat(notifications).anyMatch(n -> n.getValueString("title").equals("API key expires Nov 1, 2026: ci-bot"));
assertThat(notifications).noneMatch(n -> n.getValueString("title").startsWith("API key created"));
new NotificationEmailer(runtime).run();
assertThat(SENT).hasSize(4);
assertThat(SENT).hasSize(3);
assertThat(SENT).noneMatch(message -> text(message).contains("change your password"));
}
@@ -21,6 +21,7 @@ package com.kingsrook.qbits.alerts.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
@@ -42,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
import org.junit.jupiter.api.Test;
@@ -57,6 +59,7 @@ class NotificationSlackTest extends BaseAlertsTest
static final List<SendMessageInput> SENT = new CopyOnWriteArrayList<>();
static volatile String failureMode = "NONE";
static boolean idempotency;
@@ -76,6 +79,17 @@ class NotificationSlackTest extends BaseAlertsTest
/*******************************************************************************
** Advertise optional provider deduplication.
*******************************************************************************/
@Override
public Set<MessagingProviderCapability> getCapabilities()
{
return (idempotency ? Set.of(MessagingProviderCapability.IDEMPOTENCY) : Set.of());
}
/*******************************************************************************
**
*******************************************************************************/
@@ -107,6 +121,7 @@ class NotificationSlackTest extends BaseAlertsTest
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("slack").withType("alerts-slack-capture"));
SENT.clear();
failureMode = "NONE";
idempotency = false;
}
@@ -187,6 +202,7 @@ class NotificationSlackTest extends BaseAlertsTest
assertThat(message.getMessagingProviderName()).isEqualTo("slack");
assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1");
assertThat(message.getSubject()).isEqualTo("Export failed");
assertThat(message.getIdempotencyKey()).isNull();
assertThat(text(message)).contains("Export failed").contains("Disk full").contains("https://admin.example.com/app/widget");
});
assertThat(slackStatus("viewer-1")).isEqualTo("SENT");
@@ -200,6 +216,22 @@ class NotificationSlackTest extends BaseAlertsTest
/*******************************************************************************
** Capable providers receive a stable key for immediate notifications.
*******************************************************************************/
@Test
void testIdempotencyKey() throws Exception
{
idempotency = true;
slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null);
notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1");
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
assertThat(SENT).singleElement().satisfies(message ->
assertThat(message.getIdempotencyKey()).matches("notify:[0-9]+:viewer-1:slack"));
}
/*******************************************************************************
** A category muted in-app gets no recipient row, so no Slack message.
*******************************************************************************/
+1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS alerts_type (
quiet_period_minutes INTEGER,
external_detail_ceiling VARCHAR(16),
reminder_interval_minutes INTEGER,
reopen_window_minutes INTEGER,
last_raised_at TIMESTAMP,
open_count INTEGER,
create_date TIMESTAMP,