Skip to content

Jakarta Mail instrumentation for observability #5997

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions micrometer-jakarta9/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ jar {
bnd '''\
Import-Package: \
jakarta.jms.*;resolution:=dynamic;version="${@}",\
jakarta.mail.*;resolution:=dynamic;version="${@}",\
io.micrometer.observation.*;resolution:=dynamic;version="${@}",\
*
'''.stripIndent()
Expand All @@ -18,12 +19,16 @@ dependencies {
api project(":micrometer-observation")

optionalApi 'jakarta.jms:jakarta.jms-api'
// Jakarta 9 version of the Mail API
optionalApi 'jakarta.mail:jakarta.mail-api:2.0.1'

testImplementation(libs.archunitJunit5) {
// avoid transitively pulling in slf4j 2
exclude group: "org.slf4j", module: "slf4j-api"
}
testImplementation libs.slf4jApi
testImplementation libs.mockitoCore5
testImplementation project(':micrometer-observation-test')
testImplementation 'org.assertj:assertj-core'
testRuntimeOnly('com.sun.mail:jakarta.mail:2.0.1')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2025 VMware, Inc.
*
* 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
*
* https://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 io.micrometer.jakarta9.instrument.mail;

import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import jakarta.mail.Message;
import jakarta.mail.Message.RecipientType;

import java.util.ArrayList;
import java.util.List;

/**
* Default implementation for {@link MailSendObservationConvention}.
*
* @since 1.15.0
* @author famaridon
*/
public class DefaultMailSendObservationConvention implements MailSendObservationConvention {

@Override
public String getName() {
return "mail.send";
}

@Override
public String getContextualName(MailSendObservationContext context) {
return "mail send";
}

@Override
public KeyValues getLowCardinalityKeyValues(MailSendObservationContext context) {
return KeyValues.of(MailKeyValues.serverAddress(context), MailKeyValues.serverPort(context),
MailKeyValues.networkProtocolName(context));
}

@Override
public KeyValues getHighCardinalityKeyValues(MailSendObservationContext context) {
Message message = context.getCarrier();
List<KeyValue> values = new ArrayList<>();
MailKeyValues.smtpMessageSubject(message).ifPresent(values::add);
MailKeyValues.smtpMessageFrom(message).ifPresent(values::add);
MailKeyValues.smtpMessageRecipients(message, RecipientType.TO).ifPresent(values::add);
MailKeyValues.smtpMessageRecipients(message, RecipientType.CC).ifPresent(values::add);
MailKeyValues.smtpMessageRecipients(message, RecipientType.BCC).ifPresent(values::add);

return KeyValues.of(values);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2025 VMware, Inc.
*
* 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
*
* https://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 io.micrometer.jakarta9.instrument.mail;

import io.micrometer.common.lang.Nullable;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.ObservationRegistry;
import jakarta.mail.*;

/**
* Wraps a {@link Transport} so that it is instrumented with a Micrometer
* {@link Observation}.
*
* @since 1.15.0
* @author famaridon
*/
public class InstrumentedTransport extends Transport {

private static final DefaultMailSendObservationConvention DEFAULT_CONVENTION = new DefaultMailSendObservationConvention();

private final ObservationRegistry observationRegistry;

private final Transport delegate;

@Nullable
private final String protocol;

@Nullable
private String host;

@Nullable
private final ObservationConvention<MailSendObservationContext> customConvention;

private int port;

/**
* Create an instrumented transport using the
* {@link DefaultMailSendObservationConvention default} {@link ObservationConvention}.
* @param session session for the delegate transport
* @param delegate transport to instrument
* @param observationRegistry registry for the observations
*/
public InstrumentedTransport(Session session, Transport delegate, ObservationRegistry observationRegistry) {
this(session, delegate, observationRegistry, null);
}

/**
* Create an instrumented transport with a custom {@link MailSendObservationConvention
* convention}.
* @param session session for the delegate transport
* @param delegate transport to instrument
* @param observationRegistry registry for the observations
* @param customConvention override the convention to apply to the instrumentation
*/
public InstrumentedTransport(Session session, Transport delegate, ObservationRegistry observationRegistry,
@Nullable ObservationConvention<MailSendObservationContext> customConvention) {
super(session, delegate.getURLName());
this.protocol = this.url.getProtocol();
this.delegate = delegate;
this.observationRegistry = observationRegistry;
this.customConvention = customConvention;
}

@Override
public void connect(String host, int port, String user, String password) throws MessagingException {
this.delegate.connect(host, port, user, password);
this.host = host;
this.port = port;
}

@Override
public void sendMessage(Message msg, Address[] addresses) throws MessagingException {

Observation observation = MailObservationDocumentation.MAIL_SEND.observation(this.customConvention,
DEFAULT_CONVENTION, () -> new MailSendObservationContext(msg, this.protocol, this.host, this.port),
observationRegistry);

observation.start();
try (Observation.Scope ignore = observation.openScope()) {
// the Message-Id is set by the Transport (from the SMTP server) after sending
this.delegate.sendMessage(msg, addresses);
MailKeyValues.smtpMessageId(msg).ifPresent(observation::highCardinalityKeyValue);
}
catch (MessagingException error) {
observation.error(error);
throw error;
}
finally {
observation.stop();
}
}

@Override
public synchronized void close() throws MessagingException {
this.delegate.close();
this.host = null;
this.port = 0;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2025 VMware, Inc.
*
* 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
*
* https://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 io.micrometer.jakarta9.instrument.mail;

import io.micrometer.common.docs.KeyName;
import io.micrometer.common.lang.Nullable;
import io.micrometer.jakarta9.instrument.mail.MailObservationDocumentation.LowCardinalityKeyNames;

import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;

import io.micrometer.common.KeyValue;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Message.RecipientType;

/**
* Documented {@link io.micrometer.common.KeyValue KeyValues} for the observations on
* {@link jakarta.mail.Transport send} of mail messages.
*
* @since 1.15.0
* @author famaridon
*/
class MailKeyValues {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be part of DefaultMailSendObservationConvention?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean making it an inner class there? I suppose that makes sense given it isn't used anywhere else, and it's package private.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or copying its methods into the convention.


/**
* The value is when value can't be determined.
*/
public static final String UNKNOWN = "unknown";

private MailKeyValues() {
}

static Optional<KeyValue> smtpMessageFrom(Message message) {
return safeExtractValue(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_FROM,
() -> addressesToValue(message.getFrom()));
}

static Optional<KeyValue> smtpMessageRecipients(Message message, RecipientType recipientType) {
MailObservationDocumentation.HighCardinalityKeyNames key = MailObservationDocumentation.HighCardinalityKeyNames
.valueOf("SMTP_MESSAGE_" + recipientType.toString().toUpperCase(Locale.ROOT));
return safeExtractValue(key, () -> addressesToValue(message.getRecipients(recipientType)));
}

static Optional<KeyValue> smtpMessageSubject(Message message) {

return safeExtractValue(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_SUBJECT,
() -> Optional.ofNullable(message.getSubject()));
}

static Optional<KeyValue> smtpMessageId(Message message) {
return safeExtractValue(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_ID, () -> {
String[] header = message.getHeader("Message-ID");
if (header == null || header.length == 0) {
return Optional.empty();
}
return Optional.of(String.join(", ", header));
});
}

static KeyValue serverAddress(MailSendObservationContext context) {
String host = context.getHost();
if (host == null || host.isEmpty()) {
host = UNKNOWN;
}
return LowCardinalityKeyNames.SERVER_ADDRESS.withValue(host);
}

static KeyValue serverPort(MailSendObservationContext context) {
String port = UNKNOWN;
if (context.getPort() > 0) {
port = String.valueOf(context.getPort());
}
return LowCardinalityKeyNames.SERVER_PORT.withValue(port);
}

static KeyValue networkProtocolName(MailSendObservationContext context) {
String protocol = context.getProtocol();
if (protocol == null || protocol.isEmpty()) {
protocol = UNKNOWN;
}
return KeyValue.of(LowCardinalityKeyNames.NETWORK_PROTOCOL_NAME, protocol);
}

private static Optional<KeyValue> safeExtractValue(KeyName key, ValueExtractor extractor) {
String value;
try {
Optional<String> extracted = extractor.extract();
if (!extracted.isPresent()) {
return Optional.empty();
}
value = extracted.get();
}
catch (MessagingException exc) {
value = UNKNOWN;
}
return Optional.of(key.withValue(value));
}

private static Optional<String> addressesToValue(@Nullable Address[] addresses) {
if (addresses == null || addresses.length == 0) {
return Optional.empty();
}
String value = Arrays.stream(addresses).map(Address::toString).collect(Collectors.joining(", "));
return Optional.of(value);
}

private interface ValueExtractor {

Optional<String> extract() throws MessagingException;

}

}
Loading