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 2 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,51 @@
/*
* 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.KeyValues;
import jakarta.mail.Message;

/**
* Default implementation for {@link MailSendObservationConvention}.
*
* @since 1.15.0
*/
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();
return KeyValues.of(MailKeyValues.smtpMessageFrom(message), MailKeyValues.smtpMessageTo(message),
MailKeyValues.smtpMessageSubject(message));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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
*/
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 SMTP after sending the message
this.delegate.sendMessage(msg, addresses);
observation.highCardinalityKeyValue(MailKeyValues.smtpMessageId(msg));
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure this is a big problem since this is high cardinality data but if there will be an exception in sendMessage, this line will not be executed and the smtpMessageId keyvalue will be missing completely.
Should not we set it to unknown in that case? E.g.:
Either doing this in catch:

observation.highCardinalityKeyValue(MailKeyValues.SMTP_MESSAGE_ID_UNKNOWN);

Or moving this to finally:

observation.highCardinalityKeyValue(MailKeyValues.smtpMessageId(msg));

+ a test that simulates an exception from sendMessage.

}
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,126 @@
/*
* 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.jakarta9.instrument.mail.MailObservationDocumentation.LowCardinalityKeyNames;

import java.util.Arrays;
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;

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.


private static final KeyValue SMTP_MESSAGE_FROM_UNKNOWN = KeyValue
.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_FROM, "unknown");

private static final KeyValue SMTP_MESSAGE_TO_UNKNOWN = KeyValue
.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_TO, "unknown");

private static final KeyValue SMTP_MESSAGE_SUBJECT_UNKNOWN = KeyValue
.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_SUBJECT, "unknown");

private static final KeyValue SMTP_MESSAGE_ID_UNKNOWN = KeyValue
.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_ID, "unknown");

private static final KeyValue SERVER_ADDRESS_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.SERVER_ADDRESS,
"unknown");

private static final KeyValue SERVER_PORT_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.SERVER_PORT, "unknown");

private MailKeyValues() {
}

static KeyValue smtpMessageFrom(Message message) {
try {
if (message.getFrom() == null || message.getFrom().length == 0) {
return SMTP_MESSAGE_FROM_UNKNOWN;
}
String fromString = Arrays.stream(message.getFrom())
.map(Address::toString)
.collect(Collectors.joining(", "));
return KeyValue.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_FROM, fromString);
}
catch (MessagingException exc) {
return SMTP_MESSAGE_FROM_UNKNOWN;
}
}

static KeyValue smtpMessageTo(Message message) {
try {
Address[] recipients = message.getRecipients(RecipientType.TO);
if (recipients == null || recipients.length == 0) {
return SMTP_MESSAGE_TO_UNKNOWN;
Copy link
Member

Choose a reason for hiding this comment

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

I'm also thinking if the "optional" fields should have "unknown" value if the value is missing (e.g.: "to", "subject", maybe "from"). Maybe KeyValue.NONE_VALUE would be better for these.

Copy link
Author

Choose a reason for hiding this comment

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

I decided to use 'unknown' when the value cannot be determined (like in case of an error) and 'none' when the value is not given. This way, it's clearer, and 'unknown' is only used when we can't find out the value.

I applyed this rule to all keys.

}
String recipientsString = Arrays.stream(recipients)
.map(Address::toString)
.collect(Collectors.joining(", "));
return KeyValue.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_TO, recipientsString);
}
catch (MessagingException exc) {
return SMTP_MESSAGE_TO_UNKNOWN;
}
}

static KeyValue smtpMessageSubject(Message message) {
try {
if (message.getSubject() == null) {
return SMTP_MESSAGE_SUBJECT_UNKNOWN;
}
return KeyValue.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_SUBJECT,
message.getSubject());
}
catch (MessagingException exc) {
return SMTP_MESSAGE_SUBJECT_UNKNOWN;
}
}

static KeyValue smtpMessageId(Message message) {
try {
if (message.getHeader("Message-ID") == null) {
return SMTP_MESSAGE_ID_UNKNOWN;
}
return KeyValue.of(MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_ID,
message.getHeader("Message-ID")[0]);
Copy link
Member

Choose a reason for hiding this comment

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

Can this be empty? If so, this will go to ArrayIndexOutOfBoundsException.

Copy link
Author

Choose a reason for hiding this comment

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

I fix it

}
catch (MessagingException exc) {
return SMTP_MESSAGE_ID_UNKNOWN;
}
}

static KeyValue serverAddress(MailSendObservationContext context) {
if (context.getHost() == null) {
return SERVER_ADDRESS_UNKNOWN;
}
return KeyValue.of(LowCardinalityKeyNames.SERVER_ADDRESS, context.getHost());
}

static KeyValue serverPort(MailSendObservationContext context) {
if (context.getPort() <= 0) {
return SERVER_PORT_UNKNOWN;
}
return KeyValue.of(LowCardinalityKeyNames.SERVER_PORT, String.valueOf(context.getPort()));
}

static KeyValue networkProtocolName(MailSendObservationContext context) {
return KeyValue.of(LowCardinalityKeyNames.NETWORK_PROTOCOL_NAME, context.getProtocol());
}

}
Loading