diff --git a/micrometer-jakarta9/build.gradle b/micrometer-jakarta9/build.gradle index fe54b5a684..a0469afae9 100644 --- a/micrometer-jakarta9/build.gradle +++ b/micrometer-jakarta9/build.gradle @@ -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() @@ -18,7 +19,10 @@ dependencies { api project(":micrometer-observation") optionalApi libs.jakarta.jmsApi + // Jakarta 9 version of the Mail API + optionalApi 'jakarta.mail:jakarta.mail-api:2.0.1' + testImplementation project(':micrometer-observation-test') testImplementation(libs.archunitJunit5) { // avoid transitively pulling in slf4j 2 exclude group: "org.slf4j", module: "slf4j-api" @@ -26,4 +30,5 @@ dependencies { testImplementation libs.slf4jApi testImplementation libs.mockitoCore5 testImplementation libs.assertj + testRuntimeOnly('com.sun.mail:jakarta.mail:2.0.1') } diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConvention.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConvention.java new file mode 100644 index 0000000000..c7b845e34c --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConvention.java @@ -0,0 +1,156 @@ +/* + * 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 io.micrometer.common.docs.KeyName; +import jakarta.mail.Address; +import jakarta.mail.Message; +import jakarta.mail.Message.RecipientType; +import jakarta.mail.MessagingException; +import org.jspecify.annotations.Nullable; + +import java.util.*; +import java.util.stream.Collectors; + +import static io.micrometer.jakarta9.instrument.mail.MailObservationDocumentation.HighCardinalityKeyNames.*; +import static io.micrometer.jakarta9.instrument.mail.MailObservationDocumentation.LowCardinalityKeyNames.*; + +/** + * Default implementation for {@link MailSendObservationConvention}. + * + * @since 1.16.0 + * @author famaridon + */ +public class DefaultMailSendObservationConvention implements MailSendObservationConvention { + + private static final String UNKNOWN = "unknown"; + + @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(serverAddress(context), serverPort(context), networkProtocolName(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(MailSendObservationContext context) { + Message message = context.getCarrier(); + List values = new ArrayList<>(); + smtpMessageSubject(message).ifPresent(values::add); + smtpMessageFrom(message).ifPresent(values::add); + smtpMessageRecipients(message, RecipientType.TO).ifPresent(values::add); + smtpMessageRecipients(message, RecipientType.CC).ifPresent(values::add); + smtpMessageRecipients(message, RecipientType.BCC).ifPresent(values::add); + + return KeyValues.of(values); + } + + private KeyValue serverAddress(MailSendObservationContext context) { + String host = context.getHost(); + if (host == null || host.isEmpty()) { + return SERVER_ADDRESS.withValue(UNKNOWN); + } + return SERVER_ADDRESS.withValue(host); + } + + private KeyValue serverPort(MailSendObservationContext context) { + int port = context.getPort(); + if (port <= 0) { + return SERVER_PORT.withValue(UNKNOWN); + } + return SERVER_PORT.withValue(String.valueOf(port)); + } + + private KeyValue networkProtocolName(MailSendObservationContext context) { + String protocol = context.getProtocol(); + if (protocol == null || protocol.isEmpty()) { + return NETWORK_PROTOCOL_NAME.withValue(UNKNOWN); + } + return NETWORK_PROTOCOL_NAME.withValue(protocol); + } + + private Optional smtpMessageSubject(@Nullable Message message) { + if (message == null) { + return Optional.empty(); + } + return safeExtractValue(SMTP_MESSAGE_SUBJECT, () -> Optional.ofNullable(message.getSubject())); + } + + private Optional smtpMessageFrom(@Nullable Message message) { + if (message == null) { + return Optional.empty(); + } + return safeExtractValue(SMTP_MESSAGE_FROM, () -> addressesToValue(message.getFrom())); + } + + private Optional smtpMessageRecipients(@Nullable Message message, RecipientType recipientType) { + if (message == null) { + return Optional.empty(); + } + MailObservationDocumentation.HighCardinalityKeyNames key = MailObservationDocumentation.HighCardinalityKeyNames + .valueOf("SMTP_MESSAGE_" + recipientType.toString().toUpperCase(Locale.ROOT)); + return safeExtractValue(key, () -> addressesToValue(message.getRecipients(recipientType))); + } + + Optional smtpMessageId(@Nullable Message message) { + if (message == null) { + return Optional.empty(); + } + return safeExtractValue(SMTP_MESSAGE_ID, () -> extractHeaderValue(message, "Message-ID")); + } + + private Optional extractHeaderValue(Message message, String headerName) throws MessagingException { + String[] header = message.getHeader(headerName); + if (header == null || header.length == 0) { + return Optional.empty(); + } + return Optional.of(String.join(", ", header)); + } + + private Optional safeExtractValue(KeyName key, ValueExtractor extractor) { + try { + return extractor.extract().map(key::withValue); + } + catch (MessagingException ex) { + return Optional.of(key.withValue(UNKNOWN)); + } + } + + private Optional addressesToValue(Address @Nullable [] 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 extract() throws MessagingException; + + } + +} diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransport.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransport.java new file mode 100644 index 0000000000..3a6acd6b58 --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransport.java @@ -0,0 +1,110 @@ +/* + * 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.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.ObservationRegistry; +import jakarta.mail.*; +import org.jspecify.annotations.Nullable; + +/** + * Wraps a {@link Transport} so that it is instrumented with a Micrometer + * {@link Observation}. + * + * @since 1.16.0 + * @author famaridon + */ +public class InstrumentedTransport extends Transport { + + private static final DefaultMailSendObservationConvention DEFAULT_CONVENTION = new DefaultMailSendObservationConvention(); + + private final ObservationRegistry observationRegistry; + + private final Transport delegate; + + private final @Nullable String protocol; + + private @Nullable String host; + + private int port; + + private final @Nullable ObservationConvention customConvention; + + /** + * 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 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()) { + this.delegate.sendMessage(msg, addresses); + // the Message-Id is set by the Transport (from the SMTP server) after sending + DEFAULT_CONVENTION.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; + } + +} diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentation.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentation.java new file mode 100644 index 0000000000..dfa4d604ee --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentation.java @@ -0,0 +1,144 @@ +/* + * 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.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * Documented {@link io.micrometer.common.KeyValue KeyValues} for the observations on + * {@link jakarta.mail.Transport send} of mail messages. + * + * @since 1.16.0 + * @author famaridon + */ +public enum MailObservationDocumentation implements ObservationDocumentation { + + /** + * Observation for mail send operations. Measures the time spent sending a mail + * message. + */ + MAIL_SEND { + @Override + public Class> getDefaultConvention() { + return DefaultMailSendObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * (SMTP) Server address used for sending mails. + */ + SERVER_ADDRESS { + @Override + public String asString() { + return "server.address"; + } + }, + /** + * (SMTP) Server port used for sending mails. + */ + SERVER_PORT { + @Override + public String asString() { + return "server.port"; + } + }, + /** + * Network protocol used for sending mails. + */ + NETWORK_PROTOCOL_NAME { + @Override + public String asString() { + return "network.protocol.name"; + } + } + + } + + public enum HighCardinalityKeyNames implements KeyName { + + /** + * Sender of the mail. + */ + SMTP_MESSAGE_FROM { + @Override + public String asString() { + return "smtp.message.from"; + } + }, + /** + * Primary (TO) recipient(s) of the mail. + */ + SMTP_MESSAGE_TO { + @Override + public String asString() { + return "smtp.message.to"; + } + }, + /** + * Carbon copy (CC) recipient(s) of the mail. + */ + SMTP_MESSAGE_CC { + @Override + public String asString() { + return "smtp.message.cc"; + } + }, + /** + * Blind carbon copy (BCC) recipient(s) of the mail. + */ + SMTP_MESSAGE_BCC { + @Override + public String asString() { + return "smtp.message.bcc"; + } + }, + /** + * Subject line of the mail. + */ + SMTP_MESSAGE_SUBJECT { + @Override + public String asString() { + return "smtp.message.subject"; + } + }, + /** + * Message ID received from the SMTP server. + */ + SMTP_MESSAGE_ID { + @Override + public String asString() { + return "smtp.message.id"; + } + } + + } + +} diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationContext.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationContext.java new file mode 100644 index 0000000000..29c50df0ef --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationContext.java @@ -0,0 +1,73 @@ +/* + * 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.util.internal.logging.WarnThenDebugLogger; +import io.micrometer.observation.transport.SenderContext; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import org.jspecify.annotations.Nullable; + +/** + * Context that holds information for observation metadata collection during the + * {@link MailObservationDocumentation#MAIL_SEND sending of mail messages}. + *

+ * This propagates metadata with the message sent by + * {@link Message#setHeader(String, String) setting a message header}. + * + * @since 1.16.0 + * @author famaridon + */ +public class MailSendObservationContext extends SenderContext { + + private static final WarnThenDebugLogger logger = new WarnThenDebugLogger(MailSendObservationContext.class); + + private final @Nullable String protocol; + + private final @Nullable String host; + + private final int port; + + public MailSendObservationContext(Message msg, @Nullable String protocol, @Nullable String host, int port) { + super((message, key, value) -> { + if (message != null) { + try { + message.setHeader(key, value); + } + catch (MessagingException exc) { + logger.log("Failed to set message header.", exc); + } + } + }); + setCarrier(msg); + this.protocol = protocol; + this.host = host; + this.port = port; + } + + public @Nullable String getProtocol() { + return protocol; + } + + public @Nullable String getHost() { + return host; + } + + public int getPort() { + return port; + } + +} diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationConvention.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationConvention.java new file mode 100644 index 0000000000..0862de5735 --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/MailSendObservationConvention.java @@ -0,0 +1,35 @@ +/* + * 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.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * {@link ObservationConvention} interface for + * {@link MailObservationDocumentation#MAIL_SEND mail send} operations. + * + * @since 1.16.0 + * @author famaridon + */ +public interface MailSendObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof MailSendObservationContext; + } + +} diff --git a/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/package-info.java b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/package-info.java new file mode 100644 index 0000000000..9f69af573d --- /dev/null +++ b/micrometer-jakarta9/src/main/java/io/micrometer/jakarta9/instrument/mail/package-info.java @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Observation instrumentation for Jakarta Mail. + * + * @since 1.16.0 + */ +@NullMarked +package io.micrometer.jakarta9.instrument.mail; + +import org.jspecify.annotations.NullMarked; diff --git a/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConventionTests.java b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConventionTests.java new file mode 100644 index 0000000000..df6b4773dd --- /dev/null +++ b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/DefaultMailSendObservationConventionTests.java @@ -0,0 +1,276 @@ +/* + * 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.Address; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static io.micrometer.jakarta9.instrument.mail.MailObservationDocumentation.HighCardinalityKeyNames.*; +import static io.micrometer.jakarta9.instrument.mail.MailObservationDocumentation.LowCardinalityKeyNames.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultMailSendObservationConvention}. + * + * @author famaridon + */ +class DefaultMailSendObservationConventionTests { + + private final DefaultMailSendObservationConvention convention = new DefaultMailSendObservationConvention(); + + @Test + void shouldHaveObservationName() { + assertThat(convention.getName()).isEqualTo("mail.send"); + } + + @Test + void shouldHaveContextualName() { + MailSendObservationContext context = new MailSendObservationContext(new MimeMessage((Session) null), "smtp", + "localhost", 25); + assertThat(convention.getContextualName(context)).isEqualTo("mail send"); + } + + @Test + void shouldHaveHighCardinalityKeyValues() throws MessagingException { + MimeMessage message = new MimeMessage((Session) null); + message.setFrom(new InternetAddress("from@example.com")); + message.addRecipient(Message.RecipientType.TO, new InternetAddress("to@example.com")); + message.setSubject("Test Subject"); + + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + assertThat(convention.getHighCardinalityKeyValues(context)).contains( + KeyValue.of("smtp.message.from", "from@example.com"), KeyValue.of("smtp.message.to", "to@example.com"), + KeyValue.of("smtp.message.subject", "Test Subject")); + } + + @Test + void shouldHaveLowCardinalityKeyValues() throws MessagingException { + MimeMessage message = new MimeMessage((Session) null); + message.setFrom(new InternetAddress("from@example.com")); + message.addRecipient(Message.RecipientType.TO, new InternetAddress("to@example.com")); + message.setSubject("Test Subject"); + + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "example.com", 587); + assertThat(convention.getLowCardinalityKeyValues(context)).contains( + KeyValue.of("server.address", "example.com"), KeyValue.of("server.port", "587"), + KeyValue.of("network.protocol.name", "smtp")); + } + + @Test + void shouldHandleMessagingException() { + MimeMessage message = new MimeMessage((Session) null) { + @Override + public Address[] getFrom() throws MessagingException { + throw new MessagingException("test exception"); + } + }; + + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + assertThat(convention.getHighCardinalityKeyValues(context)) + .contains(KeyValue.of("smtp.message.from", "unknown")); + } + + @Test + void shouldHandleMultipleFromAddresses() throws MessagingException { + MimeMessage message = new MimeMessage((Session) null); + message.setFrom(new InternetAddress("from1@example.com")); + message.addFrom(new InternetAddress[] { new InternetAddress("from2@example.com") }); + + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + assertThat(convention.getHighCardinalityKeyValues(context)) + .contains(KeyValue.of("smtp.message.from", "from1@example.com, from2@example.com")); + } + + @Test + void shouldHandleMultipleToAddresses() throws MessagingException { + MimeMessage message = new MimeMessage((Session) null); + message.addRecipient(Message.RecipientType.TO, new InternetAddress("to1@example.com")); + message.addRecipient(Message.RecipientType.TO, new InternetAddress("to2@example.com")); + + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + assertThat(convention.getHighCardinalityKeyValues(context)) + .contains(KeyValue.of("smtp.message.to", "to1@example.com, to2@example.com")); + } + + @Test + void fromShouldBeUnknownWhenFetchingFails() throws MessagingException { + Message message = Mockito.mock(Message.class); + Mockito.when(message.getFrom()).thenThrow(new MessagingException("test exception")); + assertThat(getHighCardinalityKeyValues(message)).containsExactly(SMTP_MESSAGE_FROM.withValue("unknown")); + } + + @Test + void fieldsShouldBeMissingWhenUnset() { + Message message = new MimeMessage((Session) null); + assertThat(getHighCardinalityKeyValues(message)).isEmpty(); + } + + @Test + void fromShouldBeMissingWhenEmpty() throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addFrom(new InternetAddress[0]); + assertThat(getHighCardinalityKeyValues(message)).isEmpty(); + } + + @Test + void fromShouldBeThereWhenSet() throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addFrom(new InternetAddress[] { new InternetAddress("test@exemple.com") }); + assertThat(getHighCardinalityKeyValues(message)) + .containsExactly(SMTP_MESSAGE_FROM.withValue("test@exemple.com")); + } + + @Test + void multipleFromShouldBeThereWhenMultipleSet() throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addFrom(new InternetAddress[] { new InternetAddress("test@exemple.com"), + new InternetAddress("other@example.com") }); + assertThat(getHighCardinalityKeyValues(message)) + .containsExactly(SMTP_MESSAGE_FROM.withValue("test@exemple.com, other@example.com")); + } + + @ParameterizedTest + @MethodSource("recipientTypes") + void recipientsShouldBeUnknownWhenFetchingFails(Message.RecipientType recipientType) throws MessagingException { + Message message = Mockito.mock(Message.class); + Mockito.when(message.getRecipients(recipientType)).thenThrow(new MessagingException("test exception")); + KeyValue keyValue = KeyValue.of("smtp.message." + String.valueOf(recipientType).toLowerCase(Locale.ROOT), + "unknown"); + assertThat(getHighCardinalityKeyValues(message)).containsExactly(keyValue); + } + + @ParameterizedTest + @MethodSource("recipientTypes") + void recipientsShouldBeMissingWhenUnset(Message.RecipientType recipientType) throws MessagingException { + Message message = Mockito.mock(Message.class); + Mockito.when(message.getRecipients(recipientType)).thenReturn(null); + assertThat(getHighCardinalityKeyValues(message)).isEmpty(); + } + + @ParameterizedTest + @MethodSource("recipientTypes") + void recipientsShouldBeMissingWhenEmpty(Message.RecipientType recipientType) throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addRecipients(recipientType, new InternetAddress[0]); + assertThat(getHighCardinalityKeyValues(message)).isEmpty(); + } + + @ParameterizedTest + @MethodSource("recipientTypes") + void recipientsShouldBeThereWhenSet(Message.RecipientType recipientType) throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addRecipients(recipientType, new InternetAddress[] { new InternetAddress("test@exemple.com") }); + KeyValue keyValue = KeyValue.of("smtp.message." + String.valueOf(recipientType).toLowerCase(Locale.ROOT), + "test@exemple.com"); + assertThat(getHighCardinalityKeyValues(message)).containsExactly(keyValue); + } + + @ParameterizedTest + @MethodSource("recipientTypes") + void multipleRecipientsShouldBeThereWhenMultipleSet(Message.RecipientType recipientType) throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addRecipients(recipientType, new InternetAddress[] { new InternetAddress("test@exemple.com"), + new InternetAddress("other@example.com") }); + KeyValue keyValue = KeyValue.of("smtp.message." + String.valueOf(recipientType).toLowerCase(Locale.ROOT), + "test@exemple.com, other@example.com"); + assertThat(getHighCardinalityKeyValues(message)).containsExactly(keyValue); + } + + @Test + void subjectShouldBeUnknownWhenFetchingFails() throws MessagingException { + var message = Mockito.mock(Message.class); + Mockito.when(message.getSubject()).thenThrow(new MessagingException("test exception")); + assertThat(getHighCardinalityKeyValues(message)).containsExactly(SMTP_MESSAGE_SUBJECT.withValue("unknown")); + } + + @Test + void subjectShouldBeThereWhenSet() throws MessagingException { + Message message = new MimeMessage((Session) null); + message.setSubject("test subject"); + assertThat(getHighCardinalityKeyValues(message)) + .containsExactly(SMTP_MESSAGE_SUBJECT.withValue("test subject")); + } + + @Test + void protocolAddressAndPortShouldBeThereWhenSet() { + Message message = new MimeMessage((Session) null); + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + assertThat(getLowCardinalityKeyValues(context)).containsExactly(NETWORK_PROTOCOL_NAME.withValue("smtp"), + SERVER_ADDRESS.withValue("localhost"), SERVER_PORT.withValue("25")); + } + + @Test + void protocolAndAddressShouldBeUnknownWhenUnsetOrInvalid() { + Message message = new MimeMessage((Session) null); + MailSendObservationContext context = new MailSendObservationContext(message, null, null, 0); + assertThat(getLowCardinalityKeyValues(context)).containsExactly(NETWORK_PROTOCOL_NAME.withValue("unknown"), + SERVER_ADDRESS.withValue("unknown"), SERVER_PORT.withValue("unknown")); + } + + @Test + void messageIdShouldBeUnknownWhenFetchingFails() throws MessagingException { + Message message = Mockito.mock(Message.class); + Mockito.when(message.getHeader("Message-ID")).thenThrow(new MessagingException("test exception")); + assertThat(convention.smtpMessageId(message)).isPresent().hasValue(SMTP_MESSAGE_ID.withValue("unknown")); + } + + @Test + void messageIdShouldBeMissingWhenEmpty() { + Message message = new MimeMessage((Session) null); + assertThat(convention.smtpMessageId(message)).isEmpty(); + } + + @Test + void messageIdShouldBeThereWhenSet() throws MessagingException { + Message message = new MimeMessage((Session) null); + message.addHeader("Message-ID", "12345@example.com"); + assertThat(convention.smtpMessageId(message)).isPresent() + .hasValue(SMTP_MESSAGE_ID.withValue("12345@example.com")); + } + + private List getHighCardinalityKeyValues(Message message) { + MailSendObservationContext context = new MailSendObservationContext(message, "smtp", "localhost", 25); + KeyValues highCardinalityKeyValues = convention.getHighCardinalityKeyValues(context); + return highCardinalityKeyValues.stream().collect(Collectors.toList()); + } + + private List getLowCardinalityKeyValues(MailSendObservationContext context) { + KeyValues lowCardinalityKeyValues = convention.getLowCardinalityKeyValues(context); + return lowCardinalityKeyValues.stream().collect(Collectors.toList()); + } + + static Stream recipientTypes() { + return Stream.of(Arguments.of(Message.RecipientType.TO), Arguments.of(Message.RecipientType.CC), + Arguments.of(Message.RecipientType.BCC)); + } + +} diff --git a/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransportTest.java b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransportTest.java new file mode 100644 index 0000000000..c3b19a3c5d --- /dev/null +++ b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/InstrumentedTransportTest.java @@ -0,0 +1,119 @@ +/* + * 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.observation.tck.TestObservationRegistry; +import jakarta.mail.*; +import jakarta.mail.Message.RecipientType; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link InstrumentedTransport}. + * + * @author famaridon + */ +class InstrumentedTransportTest { + + TestObservationRegistry registry = TestObservationRegistry.create(); + + MockSMTPTransport.Listener listener; + + Session session; + + Transport transport; + + @BeforeEach + void setUp() throws NoSuchProviderException { + Properties smtpProperties = new Properties(); + // default use of mock to simplify test + smtpProperties.put("mail.transport.protocol", "mocksmtp"); + + listener = MockSMTPTransport.resetAndGetGlobalListener(); + session = Session.getInstance(smtpProperties); + transport = new InstrumentedTransport(session, session.getTransport("mocksmtp"), registry); + } + + @Test + void shouldDelegateConnect() throws MessagingException { + transport.connect("host", 123, "user", "password"); + verify(listener).onConnect("host", 123, "user", "password"); + } + + @Test + void shouldDelegateClose() throws MessagingException { + transport.close(); + verify(listener).onClose(); + } + + @Test + void shouldDelegateSendMessageEvenIfObservationRegistryIsNotInstalled() throws MessagingException { + Message msg = new MimeMessage(this.session); + Address[] to = new Address[0]; + transport.sendMessage(msg, to); + verify(listener).onSendMessage(msg, to); + } + + @Test + void shouldDelegateSendMessageIfObservationRegistryIsInstalled() throws MessagingException { + Message msg = new MimeMessage(this.session); + Address[] to = new Address[0]; + transport.sendMessage(msg, to); + verify(listener).onSendMessage(msg, to); + } + + @Test + void shouldCreateObservationWhenSendMessageIsCalled() throws MessagingException { + // arrange + when(listener.onSendMessage(Mockito.any(), Mockito.any())).thenAnswer(invocation -> { + Message message = (Message) invocation.getArguments()[0]; + message.setHeader("Message-Id", "message-id"); + return true; + }); + Message msg = new MimeMessage(this.session); + msg.setSubject("Hello world"); + Address[] to = new Address[0]; + msg.setFrom(new InternetAddress("from@example.com")); + msg.addRecipient(RecipientType.TO, new InternetAddress("to@example.com")); + transport.connect("example.com", 123, "user", "password"); + + // act + transport.sendMessage(msg, to); + + // assert + assertThat(registry).hasObservationWithNameEqualTo("mail.send") + .that() + .hasBeenStarted() + .hasBeenStopped() + .hasHighCardinalityKeyValue("smtp.message.id", "message-id") + .hasHighCardinalityKeyValue("smtp.message.subject", "Hello world") + .hasHighCardinalityKeyValue("smtp.message.to", "to@example.com") + .hasHighCardinalityKeyValue("smtp.message.from", "from@example.com") + .hasLowCardinalityKeyValue("server.address", "example.com") + .hasLowCardinalityKeyValue("server.port", "123") + .hasLowCardinalityKeyValue("network.protocol.name", "mocksmtp"); + } + +} diff --git a/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentationTest.java b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentationTest.java new file mode 100644 index 0000000000..4448ed445c --- /dev/null +++ b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MailObservationDocumentationTest.java @@ -0,0 +1,59 @@ +/* + * 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.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MailObservationDocumentation}. + * + * @author famaridon + */ +class MailObservationDocumentationTest { + + @Test + void testMailSendObservation() { + MailObservationDocumentation mailSend = MailObservationDocumentation.MAIL_SEND; + + // Verify default convention + Class> defaultConvention = mailSend + .getDefaultConvention(); + assertThat(defaultConvention).isEqualTo(DefaultMailSendObservationConvention.class); + + // Verify low cardinality key names + KeyName[] lowCardinalityKeyNames = mailSend.getLowCardinalityKeyNames(); + assertThat(lowCardinalityKeyNames).containsExactly( + MailObservationDocumentation.LowCardinalityKeyNames.SERVER_ADDRESS, + MailObservationDocumentation.LowCardinalityKeyNames.SERVER_PORT, + MailObservationDocumentation.LowCardinalityKeyNames.NETWORK_PROTOCOL_NAME); + + // Verify high cardinality key names + KeyName[] highCardinalityKeyNames = mailSend.getHighCardinalityKeyNames(); + assertThat(highCardinalityKeyNames).containsExactly( + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_FROM, + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_TO, + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_CC, + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_BCC, + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_SUBJECT, + MailObservationDocumentation.HighCardinalityKeyNames.SMTP_MESSAGE_ID); + } + +} diff --git a/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MockSMTPTransport.java b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MockSMTPTransport.java new file mode 100644 index 0000000000..0e9f1ab324 --- /dev/null +++ b/micrometer-jakarta9/src/test/java/io/micrometer/jakarta9/instrument/mail/MockSMTPTransport.java @@ -0,0 +1,72 @@ +/* + * 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 jakarta.mail.*; +import org.mockito.Mockito; + +import static org.mockito.Mockito.mock; + +/** + * Mock implementation of the SMTP Transport class for testing purposes. This class allows + * us to simulate the behavior of an SMTP transport without actually sending emails. + * + * @author famaridon + */ +public class MockSMTPTransport extends Transport { + + // Needs to be static since it is created by the Java Mail implementation, + // and we don't have access to the instance + private static final Listener LISTENER = mock(Listener.class); + + public MockSMTPTransport(Session session, URLName urlname) { + super(session, urlname); + LISTENER.onConstructor(session, urlname); + } + + static Listener resetAndGetGlobalListener() { + Mockito.reset(LISTENER); + return LISTENER; + } + + @Override + public synchronized void connect(String host, int port, String user, String password) throws MessagingException { + LISTENER.onConnect(host, port, user, password); + } + + @Override + public void sendMessage(Message msg, Address[] addresses) throws MessagingException { + LISTENER.onSendMessage(msg, addresses); + } + + @Override + public synchronized void close() { + LISTENER.onClose(); + } + + interface Listener { + + void onConstructor(Session session, URLName urlname); + + void onConnect(String host, int port, String user, String password) throws MessagingException; + + boolean onSendMessage(Message msg, Address[] addresses) throws MessagingException; + + void onClose(); + + } + +} diff --git a/micrometer-jakarta9/src/test/resources/META-INF/javamail.default.providers b/micrometer-jakarta9/src/test/resources/META-INF/javamail.default.providers new file mode 100644 index 0000000000..34318e9877 --- /dev/null +++ b/micrometer-jakarta9/src/test/resources/META-INF/javamail.default.providers @@ -0,0 +1 @@ +protocol=mocksmtp; type=transport; class=io.micrometer.jakarta9.instrument.mail.MockSMTPTransport; vendor=Micrometer; diff --git a/samples/micrometer-samples-jakarta10/build.gradle b/samples/micrometer-samples-jakarta10/build.gradle new file mode 100644 index 0000000000..e772ef839d --- /dev/null +++ b/samples/micrometer-samples-jakarta10/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'java' +} + +dependencies { + implementation project(":micrometer-jakarta9") + // test micrometer-jakarta9 works with Jakarta 10 Mail dependencies + implementation('jakarta.mail:jakarta.mail-api:2.1.3') + testRuntimeOnly('org.eclipse.angus:jakarta.mail:2.0.3') + + testImplementation(project(":micrometer-observation-test")) + testImplementation platform(libs.junitBom) + testImplementation(libs.junitJupiter) + testRuntimeOnly(libs.junitPlatformLauncher) +} diff --git a/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/Jakarta10InstrumentedTransportTest.java b/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/Jakarta10InstrumentedTransportTest.java new file mode 100644 index 0000000000..5f565764da --- /dev/null +++ b/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/Jakarta10InstrumentedTransportTest.java @@ -0,0 +1,80 @@ +/* + * 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.samples.jakarta10.mail; + +import io.micrometer.jakarta9.instrument.mail.InstrumentedTransport; +import io.micrometer.observation.tck.TestObservationRegistry; +import jakarta.mail.*; +import jakarta.mail.Message.RecipientType; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link InstrumentedTransport} on Jakarta 10 Mail dependencies. + */ +class Jakarta10InstrumentedTransportTest { + + Session session; + + TestObservationRegistry registry = TestObservationRegistry.create(); + + Transport transport; + + @BeforeEach + void setUp() throws NoSuchProviderException { + // prepare properties for the session + Properties smtpProperties = new Properties(); + // default use of mock to simplify test + smtpProperties.put("mail.transport.protocol", "mocksmtp"); + + // open a Session + this.session = Session.getInstance(smtpProperties); + transport = new InstrumentedTransport(session, this.session.getTransport("mocksmtp"), this.registry); + } + + @Test + void shouldCreateObservationWhenSendMessageIsCalled() throws MessagingException { + // arrange + Message msg = new MimeMessage(this.session); + msg.setSubject("Hello world"); + Address[] to = new Address[0]; + msg.setFrom(new InternetAddress("from@example.com")); + msg.addRecipient(RecipientType.TO, new InternetAddress("to@example.com")); + transport.connect("example.com", 123, "user", "password"); + + // act + transport.sendMessage(msg, to); + + // assert + assertThat(registry).hasObservationWithNameEqualTo("mail.send") + .that() + .hasBeenStarted() + .hasBeenStopped() + .hasHighCardinalityKeyValue("smtp.message.subject", "Hello world") + .hasHighCardinalityKeyValue("smtp.message.to", "to@example.com") + .hasHighCardinalityKeyValue("smtp.message.from", "from@example.com") + .hasLowCardinalityKeyValue("server.address", "example.com") + .hasLowCardinalityKeyValue("server.port", "123") + .hasLowCardinalityKeyValue("network.protocol.name", "mocksmtp"); + } + +} diff --git a/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/MockSMTPTransport.java b/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/MockSMTPTransport.java new file mode 100644 index 0000000000..6c35d79567 --- /dev/null +++ b/samples/micrometer-samples-jakarta10/src/test/java/io/micrometer/samples/jakarta10/mail/MockSMTPTransport.java @@ -0,0 +1,38 @@ +/* + * 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.samples.jakarta10.mail; + +import jakarta.mail.*; + +public class MockSMTPTransport extends Transport { + + public MockSMTPTransport(Session session, URLName urlname) { + super(session, urlname); + } + + @Override + public synchronized void connect(String host, int port, String user, String password) throws MessagingException { + } + + @Override + public void sendMessage(Message msg, Address[] addresses) throws MessagingException { + } + + @Override + public synchronized void close() { + } + +} diff --git a/samples/micrometer-samples-jakarta10/src/test/resources/META-INF/javamail.default.providers b/samples/micrometer-samples-jakarta10/src/test/resources/META-INF/javamail.default.providers new file mode 100644 index 0000000000..150f35a1a0 --- /dev/null +++ b/samples/micrometer-samples-jakarta10/src/test/resources/META-INF/javamail.default.providers @@ -0,0 +1 @@ +protocol=mocksmtp; type=transport; class=io.micrometer.samples.jakarta10.mail.MockSMTPTransport; vendor=Micrometer; diff --git a/settings.gradle b/settings.gradle index 02a3cc1953..14d29400cb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -26,7 +26,7 @@ buildCache { include 'micrometer-commons', 'micrometer-core', 'micrometer-observation' -['core', 'hazelcast', 'javalin', 'jersey3', 'jooq', 'kotlin', 'spring-framework6'].each { sample -> +['core', 'hazelcast', 'jakarta10', 'javalin', 'jersey3', 'jooq', 'kotlin', 'spring-framework6'].each { sample -> include "micrometer-samples-$sample" project(":micrometer-samples-$sample").projectDir = new File(rootProject.projectDir, "samples/micrometer-samples-$sample") }