Skip to content

Commit 7c0eef3

Browse files
committed
add metric captureing to email service
1 parent d4c2f2d commit 7c0eef3

21 files changed

Lines changed: 990 additions & 3 deletions

pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@
4949
<artifactId>lombok</artifactId>
5050
<optional>true</optional>
5151
</dependency>
52+
<!-- Optional: metrics are only recorded if the consuming application brings a MeterRegistry
53+
(usually via spring-boot-starter-actuator). Without it the library falls back to a no-op. -->
54+
<dependency>
55+
<groupId>io.micrometer</groupId>
56+
<artifactId>micrometer-core</artifactId>
57+
<optional>true</optional>
58+
</dependency>
59+
<dependency>
60+
<groupId>io.micrometer</groupId>
61+
<artifactId>micrometer-registry-prometheus</artifactId>
62+
<scope>test</scope>
63+
</dependency>
5264
<dependency>
5365
<groupId>org.springframework.boot</groupId>
5466
<artifactId>spring-boot-starter-test</artifactId>

readme.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,68 @@ To read email datasets from the database use this class: [QueryEmail.java](src%2
5656

5757
If you want to receive a report after each run of the scheduler, create a Bean implementing [EmailSchedulerCallback.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FEmailSchedulerCallback.java)
5858

59+
### Metrics
60+
61+
The library records Micrometer metrics for both schedulers. Micrometer is an optional dependency: if the
62+
application provides a `MeterRegistry` the metrics are recorded, otherwise the library falls back to a no-op
63+
and nothing changes. Nothing has to be enabled in this library.
64+
65+
To scrape them, the application needs `spring-boot-starter-actuator` and `micrometer-registry-prometheus`,
66+
plus the Prometheus endpoint:
67+
68+
```yaml
69+
management:
70+
endpoints:
71+
access:
72+
default: none
73+
web:
74+
exposure:
75+
include:
76+
- health
77+
- prometheus
78+
endpoint:
79+
health:
80+
access: read-only
81+
prometheus:
82+
access: read-only
83+
```
84+
85+
The following series are exposed:
86+
87+
| Series | Type | Labels | Description |
88+
|-------------------------------------------------|---------|--------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
89+
| `app_email_send_duration_seconds` | timer | `mode`: `scheduled`, `direct`<br/>`outcome`: `sent`, `retry`, `error` | One observation per send attempt. `retry` is an attempt that failed but is scheduled for another one, `error` an email that has given up. |
90+
| `app_email_cleanup_duration_seconds` | timer | `outcome`: `cleaned`, `error` | One observation per attachment cleanup attempt. |
91+
| `app_email_pass_duration_seconds` | timer | `job`: `send`, `cleanup`<br/>`status`: `success`, `failed` | One observation per scheduler pass. `failed` means the pass itself broke, e.g. the database was unreachable. |
92+
| `app_email_last_run_timestamp_seconds` | gauge | `job`: `send`, `cleanup` | When the scheduler last fired. Stalls if the scheduler is dead or the pod is down. |
93+
| `app_email_last_success_timestamp_seconds` | gauge | `job`: `send`, `cleanup` | When a pass last got through. Stalls while passes keep failing. |
94+
| `app_email_queue` | gauge | `state`: `pending`, `sending` | Emails per state, read after each send pass. The terminal states `SENT` and `ERROR` are left out: they only ever grow. Errors are counted by `app_email_send_duration_seconds_count{outcome="error"}`. |
95+
| `app_email_queue_oldest_due_age_seconds` | gauge | `state`: `pending` | How long the oldest email that is already due has been waiting. `0` if nothing is due. |
96+
97+
Two things to keep in mind when querying them:
98+
99+
- **Aggregate the gauges with `max`, never `sum`.** The queue gauges are read from the database, so every pod
100+
reports the same numbers - summing them multiplies the backlog by the number of pods.
101+
- The queue gauges are only written by the send scheduler. With
102+
`aboutbits.emailservice.scheduling.enabled=false` they are never registered, and the series are absent
103+
rather than zero.
104+
105+
Both timestamp gauges are registered on first use, so a pod that has not completed a pass since starting has
106+
no series at all. This is deliberate: an alert reads an absent series the same way it reads a `NaN` starting
107+
value, while a `0` starting value would look like decades of staleness after every deploy.
108+
109+
The dashboards and alerts themselves belong to the consuming application, since they depend on how it is
110+
deployed. As a starting point, a scheduler that has stopped firing and a backlog that is not being kept up
111+
with read as:
112+
113+
```promql
114+
time() - max by (job) (app_email_last_run_timestamp_seconds) > 300
115+
max(app_email_queue_oldest_due_age_seconds) > 600
116+
```
117+
118+
If the application replaces the metrics sink with its own `EmailMetrics` bean, the library's one steps back;
119+
whatever the application provides is called fail-safe, so a failing sink can never break a send.
120+
59121
### Configuration
60122

61123
To enable this service just add `@EnableEmailService` to your main class. You must also enable `@EnableScheduling` to allow the email queue to be processed.

src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import it.aboutbits.springboot.emailservice.lib.AttachmentCleanerCallback;
44
import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource;
5+
import it.aboutbits.springboot.emailservice.lib.EmailMetrics;
56
import it.aboutbits.springboot.emailservice.lib.EmailSchedulerCallback;
67
import it.aboutbits.springboot.emailservice.lib.application.CleanupAttachmentFiles;
78
import it.aboutbits.springboot.emailservice.lib.application.EmailAttachmentMapper;
@@ -14,6 +15,7 @@
1415
import it.aboutbits.springboot.emailservice.lib.application.SendScheduledEmails;
1516
import it.aboutbits.springboot.emailservice.lib.application.UnavailableAttachmentDataSource;
1617
import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository;
18+
import it.aboutbits.springboot.emailservice.lib.metrics.FailSafeEmailMetrics;
1719
import org.jspecify.annotations.NullMarked;
1820
import org.springframework.beans.factory.annotation.Value;
1921
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
@@ -27,6 +29,10 @@
2729
import java.time.Duration;
2830
import java.util.List;
2931

32+
/*
33+
* Imported through EmailServiceImportSelector, never directly: see there for why the import is deferred
34+
* and where the EmailMetrics bean comes from.
35+
*/
3036
@AutoConfigurationPackage
3137
@NullMarked
3238
public class EmailServiceConfiguration {
@@ -52,11 +58,13 @@ public QueryEmail queryEmail(EmailRepository emailRepository, EmailMapper emailM
5258
}
5359

5460
@Bean
61+
@SuppressWarnings("checkstyle:ParameterNumber")
5562
public ManageEmail manageEmail(
5663
EmailRepository emailRepository,
5764
JavaMailSender javaMailSender,
5865
AttachmentDataSource attachmentDataSource,
5966
EmailMapper emailMapper,
67+
EmailMetrics emailMetrics,
6068
@Value("${aboutbits.emailservice.scheduling.max-attempts:3}") int maxAttempts,
6169
@Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis,
6270
PlatformTransactionManager transactionManager
@@ -66,6 +74,7 @@ public ManageEmail manageEmail(
6674
javaMailSender,
6775
attachmentDataSource,
6876
emailMapper,
77+
failSafe(emailMetrics),
6978
maxAttempts,
7079
Duration.ofMillis(schedulerIntervalMillis),
7180
transactionManager
@@ -78,9 +87,16 @@ public SendScheduledEmails sendScheduledEmails(
7887
QueryEmail queryEmail,
7988
ManageEmail manageEmail,
8089
List<EmailSchedulerCallback> callbacks,
90+
EmailMetrics emailMetrics,
8191
@Value("${aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold:PT30M}") Duration stuckSendingRecoveryThreshold
8292
) {
83-
return new SendScheduledEmails(queryEmail, manageEmail, callbacks, stuckSendingRecoveryThreshold);
93+
return new SendScheduledEmails(
94+
queryEmail,
95+
manageEmail,
96+
callbacks,
97+
failSafe(emailMetrics),
98+
stuckSendingRecoveryThreshold
99+
);
84100
}
85101

86102
@Bean
@@ -89,14 +105,28 @@ public CleanupAttachmentFiles cleanupAttachments(
89105
QueryEmail queryEmail,
90106
ManageEmail manageEmail,
91107
List<AttachmentCleanerCallback> callbacks,
108+
EmailMetrics emailMetrics,
92109
@Value("${aboutbits.emailservice.scheduling.stuck-cleanup-recovery-threshold:PT30M}") Duration stuckCleanupRecoveryThreshold
93110
) {
94-
return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks, stuckCleanupRecoveryThreshold);
111+
return new CleanupAttachmentFiles(
112+
queryEmail,
113+
manageEmail,
114+
callbacks,
115+
failSafe(emailMetrics),
116+
stuckCleanupRecoveryThreshold
117+
);
95118
}
96119

97120
@Bean
98121
@ConditionalOnMissingBean(AttachmentDataSource.class)
99122
public AttachmentDataSource attachmentDataSource() {
100123
return new UnavailableAttachmentDataSource();
101124
}
125+
126+
// The EmailMetrics bean itself stays whatever the application sees - the library's own or a
127+
// replacement - but nothing inside the library talks to it unguarded: an email that went out over
128+
// SMTP has to be persisted as SENT no matter what a metrics backend does.
129+
private static EmailMetrics failSafe(EmailMetrics emailMetrics) {
130+
return new FailSafeEmailMetrics(emailMetrics);
131+
}
102132
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package it.aboutbits.springboot.emailservice;
2+
3+
import org.jspecify.annotations.NullMarked;
4+
import org.springframework.context.annotation.DeferredImportSelector;
5+
import org.springframework.core.Ordered;
6+
import org.springframework.core.type.AnnotationMetadata;
7+
8+
/*
9+
* Registers the library the way an auto-configuration is registered: only after every configuration
10+
* class of the application has been parsed. That is what lets the @ConditionalOnMissingBean fallbacks
11+
* (AttachmentDataSource, EmailMetrics) see a replacement the application declares, even one declared
12+
* in the very class that carries @EnableEmailService - a plain @Import is processed before the
13+
* importing class, so such a replacement would only be found after the fallback has already been
14+
* registered.
15+
*
16+
* The metrics configurations are listed here instead of being nested in EmailServiceConfiguration:
17+
* Spring only picks up nested configuration classes below a class annotated @Configuration, and
18+
* EmailServiceConfiguration is not one.
19+
*/
20+
@NullMarked
21+
public class EmailServiceImportSelector implements DeferredImportSelector, Ordered {
22+
@Override
23+
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
24+
return new String[]{
25+
EmailServiceConfiguration.class.getName(),
26+
MicrometerEmailMetricsConfiguration.class.getName(),
27+
NoOpEmailMetricsConfiguration.class.getName()
28+
};
29+
}
30+
31+
// Spring Boot's auto-configuration runs at LOWEST_PRECEDENCE - 1 and expects the
32+
// @AutoConfigurationPackage of EmailServiceConfiguration to be registered by then, since that is
33+
// where it finds the entities and repositories of this library.
34+
@Override
35+
public int getOrder() {
36+
return Ordered.LOWEST_PRECEDENCE - 2;
37+
}
38+
}

src/main/java/it/aboutbits/springboot/emailservice/EnableEmailService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99

1010
@Target({ElementType.TYPE})
1111
@Retention(RetentionPolicy.RUNTIME)
12-
@Import({EmailServiceConfiguration.class})
12+
@Import({EmailServiceImportSelector.class})
1313
public @interface EnableEmailService {
1414
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package it.aboutbits.springboot.emailservice;
2+
3+
import io.micrometer.core.instrument.MeterRegistry;
4+
import it.aboutbits.springboot.emailservice.lib.EmailMetrics;
5+
import it.aboutbits.springboot.emailservice.lib.metrics.MicrometerEmailMetrics;
6+
import it.aboutbits.springboot.emailservice.lib.metrics.NoOpEmailMetrics;
7+
import org.jspecify.annotations.NullMarked;
8+
import org.springframework.beans.factory.ObjectProvider;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
10+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
11+
import org.springframework.context.annotation.Bean;
12+
import org.springframework.context.annotation.Configuration;
13+
14+
/*
15+
* Micrometer is an optional dependency, so this configuration and NoOpEmailMetricsConfiguration are
16+
* guarded by mutually exclusive class conditions rather than by bean ordering: exactly one of them ever
17+
* applies, and this one, the only one referencing MeterRegistry, is only loaded once that class exists.
18+
*/
19+
@Configuration(proxyBeanMethods = false)
20+
@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")
21+
@NullMarked
22+
class MicrometerEmailMetricsConfiguration {
23+
// Micrometer on the classpath without a registry bean - micrometer-core without actuator - still
24+
// leaves nothing to record into, hence the fallback rather than a required dependency. The same goes
25+
// for several registries without a primary one: a library that only records "if a registry happens
26+
// to be there" must not fail the whole context over an ambiguity it cannot resolve.
27+
@Bean
28+
@ConditionalOnMissingBean(EmailMetrics.class)
29+
EmailMetrics emailMetrics(ObjectProvider<MeterRegistry> meterRegistry) {
30+
var registry = meterRegistry.getIfUnique();
31+
32+
if (registry == null) {
33+
return new NoOpEmailMetrics();
34+
}
35+
36+
return new MicrometerEmailMetrics(registry);
37+
}
38+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package it.aboutbits.springboot.emailservice;
2+
3+
import it.aboutbits.springboot.emailservice.lib.EmailMetrics;
4+
import it.aboutbits.springboot.emailservice.lib.metrics.NoOpEmailMetrics;
5+
import org.jspecify.annotations.NullMarked;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
7+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
11+
// Counterpart of MicrometerEmailMetricsConfiguration for applications without Micrometer on the classpath.
12+
@Configuration(proxyBeanMethods = false)
13+
@ConditionalOnMissingClass("io.micrometer.core.instrument.MeterRegistry")
14+
@NullMarked
15+
class NoOpEmailMetricsConfiguration {
16+
@Bean
17+
@ConditionalOnMissingBean(EmailMetrics.class)
18+
EmailMetrics emailMetrics() {
19+
return new NoOpEmailMetrics();
20+
}
21+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package it.aboutbits.springboot.emailservice.lib;
2+
3+
import org.jspecify.annotations.NullMarked;
4+
import org.jspecify.annotations.Nullable;
5+
6+
import java.time.Duration;
7+
import java.util.function.Supplier;
8+
9+
/*
10+
* Sink for everything the schedulers learn while running. Implemented by the library against
11+
* Micrometer when the consuming application provides a MeterRegistry, and by a no-op otherwise,
12+
* so that neither the schedulers nor their callers need to know whether metrics are enabled.
13+
*/
14+
@NullMarked
15+
public interface EmailMetrics {
16+
void sendAttempt(SendMode mode, SendOutcome outcome, Duration duration);
17+
18+
void cleanupAttempt(CleanupOutcome outcome, Duration duration);
19+
20+
void pass(Job job, PassStatus status, Duration duration);
21+
22+
// Takes a supplier because the snapshot costs database queries: a sink that has no backend to
23+
// record into is expected to never read it.
24+
void queue(Supplier<QueueSnapshot> snapshot);
25+
26+
/*
27+
* Runs a whole scheduler pass and records it, so that a database outage - the one failure the pass
28+
* itself cannot report per email - still shows up as a failed pass before it propagates to the
29+
* scheduler. Recorded in a finally block on purpose: an Error is a fired pass as much as an
30+
* exception is, and last_run is defined as the time the scheduler last fired.
31+
*/
32+
default void timedPass(Job job, Runnable pass) {
33+
var startNanos = System.nanoTime();
34+
var status = PassStatus.FAILED;
35+
36+
try {
37+
pass.run();
38+
status = PassStatus.SUCCESS;
39+
} finally {
40+
pass(job, status, Duration.ofNanos(System.nanoTime() - startNanos));
41+
}
42+
}
43+
44+
enum SendMode {
45+
SCHEDULED,
46+
DIRECT
47+
}
48+
49+
enum SendOutcome {
50+
SENT,
51+
// The attempt failed but the email is scheduled for another one, so it is not an error yet.
52+
RETRY,
53+
ERROR
54+
}
55+
56+
enum CleanupOutcome {
57+
CLEANED,
58+
ERROR
59+
}
60+
61+
enum Job {
62+
SEND,
63+
CLEANUP
64+
}
65+
66+
enum PassStatus {
67+
SUCCESS,
68+
FAILED
69+
}
70+
71+
/*
72+
* State of the queue as a whole, read once per scheduler pass. Since every pod reads the same
73+
* rows, the resulting series are identical across pods and must be aggregated with max(), not sum().
74+
*
75+
* Only the two states an email passes through are counted. SENT and ERROR are terminal, so their
76+
* counts only ever grow and say nothing about how the queue is doing right now; how many emails
77+
* end in an error is already answered by the outcome tag of the send attempts.
78+
*/
79+
record QueueSnapshot(
80+
long pending,
81+
long sending,
82+
// How long the oldest email that is already due has been waiting; null if nothing is due.
83+
@Nullable Duration oldestDueAge
84+
) {
85+
}
86+
}

0 commit comments

Comments
 (0)