diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..89d6119 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,11 @@ +name: Maven Build Artifact + +on: + pull_request: + branches: + - '*' + +jobs: + build: + uses: valitydev/java-workflow/.github/workflows/maven-library-build.yml@v3 + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..bf6ae80 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,19 @@ +name: Maven Deploy Artifact + +on: + push: + branches: + - 'master' + - 'main' + - 'rc/**' + +jobs: + deploy: + uses: valitydev/java-workflow/.github/workflows/maven-library-deploy.yml@v3 + secrets: + server-username: ${{ secrets.OSSRH_USERNAME }} + server-password: ${{ secrets.OSSRH_TOKEN }} + deploy-secret-key: ${{ secrets.OSSRH_GPG_SECRET_KEY }} + deploy-secret-key-password: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ed5a9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.factorypath +.vscode/ +*.swp +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index e318737..b24034f 100644 --- a/README.md +++ b/README.md @@ -1 +1,228 @@ -# otel-baggify \ No newline at end of file +# otel-baggify + +Библиотека для декларативного обогащения OpenTelemetry Baggage через Spring AOP аннотации. + +## Назначение + +`otel-baggify` предоставляет Spring AOP механизм для извлечения значений из аргументов метода и обогащения OpenTelemetry Baggage на время выполнения метода. Это позволяет прозрачно передавать контекстную информацию через границы сервисов без модификации бизнес-логики. + +## Требования + +- Java 17+ +- Spring Boot 3.x +- OpenTelemetry API 1.35+ + +## Подключение + +```xml + + dev.vality + otel-baggify + 1.0.0-SNAPSHOT + +``` + +## Быстрый старт + +### Базовое использование + +```java +@Service +public class UserService { + + @WithBaggage(@BaggageField(key = "user.id", path = "#userId")) + public User getUser(String userId) { + // Baggage содержит "user.id" во время выполнения метода + return userRepository.findById(userId); + } +} +``` + +### Несколько полей + +```java +@WithBaggage({ + @BaggageField(key = "user.id", path = "#request.userId"), + @BaggageField(key = "order.id", path = "#request.orderId"), + @BaggageField(key = "tenant", path = "#tenantId") +}) +public void processOrder(OrderRequest request, String tenantId) { + // Все значения доступны в Baggage +} +``` + +### Вложенные поля + +```java +@WithBaggage(@BaggageField(key = "customer.email", path = "#order.customer.email")) +public void notifyCustomer(Order order) { + // Извлекается order.getCustomer().getEmail() +} +``` + +### Запись в Span Attributes + +По умолчанию все поля записываются и в Baggage, и в Span Attributes (`addToSpanAttributes = true`). + +```java +@WithSpan +@WithBaggage(@BaggageField(key = "user.id", path = "#userId")) +public void tracedOperation(String userId) { + // Значение записывается и в Baggage, и в Span Attributes (по умолчанию) +} +``` + +Можно отключить запись в Span Attributes для отдельных полей: + +```java +@WithSpan +@WithBaggage({ + @BaggageField(key = "user.id", path = "#userId"), // -> Baggage + Span Attributes + @BaggageField(key = "internal.trace", path = "#traceId", addToSpanAttributes = false) // -> только Baggage +}) +public void tracedOperation(String userId, String traceId) { + // user.id виден в span attributes, internal.trace — только в baggage +} +``` + +## API + +### @WithBaggage + +Основная аннотация, применяется к методам. + +| Параметр | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `value` | `BaggageField[]` | - | Массив полей для извлечения | + +### @BaggageField + +Описывает одно соответствие между путём к значению и ключом в Baggage. + +| Параметр | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `key` | `String` | - | Имя ключа в Baggage (обязательный) | +| `path` | `String` | - | Путь к значению (обязательный) | +| `addToSpanAttributes` | `boolean` | `true` | Записывать ли в Span Attributes | +| `converter` | `Class>` | `NoOp` | Класс кастомного конвертера | +| `converterBean` | `String` | `""` | Имя Spring Bean конвертера | + +## Синтаксис выражений + +`path` — это SpEL-выражение. Поддерживаются: + +``` +#userId // параметр по имени +#request.userId // вложенное свойство аргумента +#order.customer?.email // null-safe навигация +#p0 // параметр по индексу +defaultTenantId // свойство target-объекта (root object) +getDefaultTenantId() // метод target-объекта +``` + +> **Примечание:** Для доступа к параметрам по имени (`#userId`) имена параметров должны быть доступны в рантайме (`true`). Можно использовать `#p0/#a0` как fallback. + +## Конвертация значений + +Порядок выбора механизма конвертации (от высшего приоритета к низшему): + +1. **Кастомный конвертер** (через `converterBean` или `converter`) +2. **Spring ConversionService** (если доступен и может конвертировать) +3. **Строка как есть** (если значение уже `String`) +4. **toString()** (fallback) + +### Пример кастомного конвертера + +```java +// Класс конвертера +public class UserIdConverter implements BaggageValueConverter { + @Override + public String convert(UserId value) { + return value != null ? value.getValue() : null; + } +} + +// Использование +@BaggageField(key = "user.id", path = "#userId", converter = UserIdConverter.class) +``` + +### Spring Bean конвертер + +```java +@Component("maskedEmailConverter") +public class MaskedEmailConverter implements BaggageValueConverter { + @Override + public String convert(String email) { + // Маскирование email для логов + return email.replaceAll("(?<=.{2}).(?=.*@)", "*"); + } +} + +// Использование +@BaggageField(key = "user.email", path = "#email", converterBean = "maskedEmailConverter") +``` + +## Порядок выполнения аспектов + +При совместном использовании с `@WithSpan`, аспект `@WithBaggage` выполняется **после** `@WithSpan`, то есть обогащение Baggage происходит внутри уже активного Span. + +Это обеспечивается через Spring AOP ordering: `WithBaggageAspect` имеет порядок `Ordered.LOWEST_PRECEDENCE - 100`. + +## Обработка null значений + +- Если значение не найдено или равно `null`, запись в Baggage **не выполняется** +- Исключения **не выбрасываются** при отсутствии значения +- При отсутствии активного Span, запись в Span Attributes безопасно пропускается + +## Восстановление контекста + +После завершения метода (успешного или с исключением) исходный OTEL контекст и Baggage автоматически восстанавливаются без утечек. + +## Конфигурация + +Библиотека использует Spring Boot Auto Configuration. Все необходимые бины создаются автоматически при наличии OpenTelemetry на classpath. + +Для кастомизации можно переопределить бины: + +```java +@Configuration +public class CustomBaggifyConfig { + + @Bean + public PathValueExtractor customPathValueExtractor() { + // Ваша реализация + } + + @Bean + public BaggageValueConverterResolver customConverterResolver( + BeanFactory beanFactory, + ConversionService conversionService) { + // Ваша реализация + } +} +``` + +## Обработка ошибок + +Библиотека **никогда не ломает бизнес-логику**. При любых проблемах с конфигурацией или извлечением значений: + +- Метод выполняется как обычно +- В лог пишется предупреждение (WARN) +- Проблемное поле просто пропускается + +Примеры ситуаций, которые логируются как предупреждения: + +- `key` пустой или `null` +- невалидное SpEL-выражение в `path` +- Параметр с указанным именем не найден +- Дублирующиеся ключи в одной аннотации +- Ошибка в кастомном конвертере + +``` +WARN PathValueExtractor : Failed to extract value at path 'bad(' from method 'process': ... +WARN WithBaggageAspect : Method 'process': Duplicate baggage key 'user.id', only first occurrence will be used +``` + +## Лицензия + +Apache License 2.0 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bbf7acc --- /dev/null +++ b/pom.xml @@ -0,0 +1,201 @@ + + + 4.0.0 + + + dev.vality + library-parent-pom + 3.1.0 + + + otel-baggify + 1.0.0 + jar + + otel-baggify + Declarative OpenTelemetry Baggage enrichment via Spring AOP annotations + https://github.com/valitydev/otel-baggify + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + Vality.Dev + https://vality.dev/ + + + + + scm:git:git://github.com/valitydev/otel-baggify.git + scm:git:ssh://github.com/valitydev/otel-baggify.git + https://github.com/valitydev/otel-baggify/tree/master + + + + UTF-8 + 17 + + + 1.35.0 + 2.1.0 + + + + + + + org.springframework.boot + spring-boot-dependencies + 3.2.2 + pom + import + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-bom + ${opentelemetry-instrumentation.version} + pom + import + + + org.junit + junit-bom + 5.10.1 + pom + import + + + + + + + + org.projectlombok + lombok + provided + + + + + org.slf4j + slf4j-api + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-aop + + + org.springframework + spring-context + + + org.aspectj + aspectjweaver + + + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-context + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-annotations + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-aop + test + + + io.opentelemetry + opentelemetry-sdk + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + true + + + + + diff --git a/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java b/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java new file mode 100644 index 0000000..0d88800 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java @@ -0,0 +1,108 @@ +package dev.vality.otel.baggify.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import dev.vality.otel.baggify.converter.BaggageValueConverter; + +/** + * Describes a single baggage field mapping from method argument to baggage key. + * + *

Example usage: + *

{@code
+ * @WithBaggage({
+ *     @BaggageField(key = "user.id", path = "#userId"),
+ *     @BaggageField(key = "order.id", path = "#request.orderId", addToSpanAttributes = false)
+ * })
+ * public void processOrder(String userId, OrderRequest request) {
+ *     // baggage is enriched during method execution
+ *     // user.id is also added to span attributes (default)
+ *     // order.id is only in baggage
+ * }
+ * }
+ * + * @see WithBaggage + * @see BaggageValueConverter + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({}) // Only used as part of @WithBaggage +public @interface BaggageField { + + /** + * The baggage key name. + *

+ * Must be non-empty and unique within a single {@link WithBaggage} annotation. + * + * @return the baggage key + */ + String key(); + + /** + * SpEL expression to extract a value. + *

+ * Supports: + *

+ * + *

Examples: + *

+ * + *

Note: Named argument access ({@code #paramName}) requires parameter names + * to be available at runtime. Spring Boot Starter Parent enables this automatically. + * + * @return the extraction path + */ + String path(); + + /** + * Whether to also write this field's value to the current span's attributes. + *

+ * When {@code true} (default): + *

+ *

+ * When {@code false}: + *

+ * + * @return {@code true} to add value to span attributes (default) + */ + boolean addToSpanAttributes() default true; + + /** + * Optional custom converter class for value transformation. + *

+ * When specified, this converter takes highest priority over all other + * conversion mechanisms (including Spring's ConversionService). + *

+ * The converter class must have a no-arg constructor. + * + * @return the converter class, or {@link BaggageValueConverter.NoOp} if not specified + */ + Class> converter() default BaggageValueConverter.NoOp.class; + + /** + * Optional Spring bean name of a custom converter. + *

+ * When specified (non-empty), the bean is looked up from the Spring context + * and used for value transformation. Takes highest priority when both + * {@link #converter()} and {@link #converterBean()} are specified. + * + * @return the converter bean name, or empty string if not specified + */ + String converterBean() default ""; +} diff --git a/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java new file mode 100644 index 0000000..dd83612 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java @@ -0,0 +1,71 @@ +package dev.vality.otel.baggify.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declarative annotation for enriching OpenTelemetry Baggage during method execution. + * + *

This annotation provides a Spring AOP mechanism to extract values from method arguments + * and write them to OpenTelemetry Baggage. The baggage enrichment is scoped to the method + * execution time - after the method completes (success or exception), the original OTEL + * context is restored. + * + *

Basic Usage

+ *
{@code
+ * @WithBaggage(@BaggageField(key = "user.id", path = "#userId"))
+ * public void processUser(String userId) {
+ *     // Baggage AND Span Attributes contain "user.id" (addToSpanAttributes=true by default)
+ * }
+ * }
+ * + *

Multiple Fields with Different Settings

+ *
{@code
+ * @WithBaggage({
+ *     @BaggageField(key = "user.id", path = "#request.userId"),
+ *     @BaggageField(key = "order.id", path = "#request.orderId"),
+ *     @BaggageField(key = "internal.trace", path = "#traceId", addToSpanAttributes = false)
+ * })
+ * public void processOrder(OrderRequest request, String traceId) {
+ *     // user.id and order.id -> Baggage + Span Attributes
+ *     // internal.trace -> Baggage only
+ * }
+ * }
+ * + *

Aspect Ordering

+ *

When used together with {@code @WithSpan}, the {@code @WithBaggage} aspect executes + * after {@code @WithSpan}, meaning baggage enrichment happens inside the active span. + * This is achieved through Spring AOP ordering (this aspect has lower priority). + * + *

Requirements

+ * + * + * @see BaggageField + * @see io.opentelemetry.api.baggage.Baggage + * @see io.opentelemetry.instrumentation.annotations.WithSpan + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface WithBaggage { + + /** + * Array of baggage field mappings. + *

+ * Each field defines a key-path pair for baggage enrichment. + * Keys must be unique within this annotation. + *

+ * By default, each field is also added to span attributes + * (configurable per field via {@link BaggageField#addToSpanAttributes()}). + * + * @return array of baggage field definitions + */ + BaggageField[] value(); +} diff --git a/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java new file mode 100644 index 0000000..b6a8919 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java @@ -0,0 +1,241 @@ +package dev.vality.otel.baggify.aspect; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import dev.vality.otel.baggify.annotation.BaggageField; +import dev.vality.otel.baggify.annotation.WithBaggage; +import dev.vality.otel.baggify.converter.BaggageValueConverterResolver; +import dev.vality.otel.baggify.extractor.PathValueExtractor; +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.baggage.BaggageBuilder; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; + +/** + * Spring AOP aspect that handles {@link WithBaggage} annotation processing. + * + *

Functionality

+ * + * + *

Error Handling

+ *

This aspect is designed to never break business logic. All errors during + * baggage enrichment are logged as warnings and silently ignored. The original + * method will always be executed regardless of any issues with baggage processing. + * + *

Aspect Ordering

+ *

This aspect has {@code Ordered.LOWEST_PRECEDENCE - 100} priority to ensure + * it executes after the OpenTelemetry {@code @WithSpan} aspect. + * + * @see WithBaggage + * @see BaggageField + */ +@Slf4j +@Aspect +@RequiredArgsConstructor +public class WithBaggageAspect implements Ordered { + + /** + * Order value for this aspect. + */ + public static final int ASPECT_ORDER = Ordered.LOWEST_PRECEDENCE - 100; + + private final PathValueExtractor pathValueExtractor; + private final BaggageValueConverterResolver converterResolver; + + @Override + public int getOrder() { + return ASPECT_ORDER; + } + + /** + * Around advice that processes {@link WithBaggage} annotated methods. + *

+ * This method never throws exceptions related to baggage processing. + * All errors are logged and the original method is always executed. + * + * @param joinPoint the join point representing the intercepted method + * @param withBaggage the WithBaggage annotation + * @return the result of the method invocation + * @throws Throwable if the method throws an exception + */ + @Around("@annotation(withBaggage)") + public Object aroundWithBaggage(ProceedingJoinPoint joinPoint, + WithBaggage withBaggage) throws Throwable { + Context enrichedContext = null; + + try { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Object[] args = joinPoint.getArgs(); + Object target = joinPoint.getTarget(); + + // Validate configuration and log warnings (don't fail) + if (!validateConfiguration(withBaggage, method)) { + return joinPoint.proceed(); + } + + // Build enriched baggage + BaggageBuilder baggageBuilder = Baggage.current().toBuilder(); + Span currentSpan = Span.current(); + boolean hasEnrichment = false; + + for (BaggageField field : withBaggage.value()) { + if (processField(field, method, args, target, baggageBuilder, currentSpan)) { + hasEnrichment = true; + } + } + + // Only create new context if we actually enriched something + if (hasEnrichment) { + Context currentContext = Context.current(); + Baggage enrichedBaggage = baggageBuilder.build(); + enrichedContext = currentContext.with(enrichedBaggage); + } + } catch (Exception e) { + log.warn("Failed to enrich baggage for method '{}': {}", + joinPoint.getSignature().getName(), e.getMessage()); + if (log.isDebugEnabled()) { + log.debug("Baggage enrichment error details", e); + } + } + + // Execute method with enriched context (or original if enrichment failed) + if (enrichedContext != null) { + try (Scope ignored = enrichedContext.makeCurrent()) { + return joinPoint.proceed(); + } + } else { + return joinPoint.proceed(); + } + } + + /** + * Validates the @WithBaggage configuration. + * Logs warnings for any issues but doesn't throw exceptions. + * + * @param withBaggage the annotation to validate + * @param method the annotated method + * @return true if configuration is valid and processing should continue + */ + private boolean validateConfiguration(WithBaggage withBaggage, Method method) { + BaggageField[] fields = withBaggage.value(); + String methodName = method.getName(); + + if (fields == null || fields.length == 0) { + log.warn("Method '{}': @WithBaggage has no @BaggageField definitions, skipping", + methodName); + return false; + } + + Set seenKeys = new HashSet<>(); + boolean hasValidFields = false; + + for (BaggageField field : fields) { + String key = field.key(); + String path = field.path(); + + // Validate key + if (key == null || key.isBlank()) { + log.warn("Method '{}': @BaggageField has empty key, skipping this field", + methodName); + continue; + } + + // Check uniqueness + if (!seenKeys.add(key)) { + log.warn("Method '{}': Duplicate baggage key '{}', " + + "only first occurrence will be used", methodName, key); + continue; + } + + // Validate path + if (path == null || path.isBlank()) { + log.warn("Method '{}': @BaggageField with key '{}' has empty path, skipping", + methodName, key); + continue; + } + + hasValidFields = true; + } + + return hasValidFields; + } + + /** + * Processes a single baggage field: extracts value, converts it, + * and adds to baggage/span. + *

+ * Never throws exceptions - all errors are logged as warnings. + * + * @param field the baggage field definition + * @param method the method being invoked + * @param args the method arguments + * @param baggageBuilder the baggage builder to add value to + * @param currentSpan the current span + * @return true if value was successfully added to baggage + */ + private boolean processField(BaggageField field, Method method, Object[] args, Object target, + BaggageBuilder baggageBuilder, Span currentSpan) { + String key = field.key(); + String path = field.path(); + + try { + // Extract value using path + Object value = pathValueExtractor.extractValue(path, method, args, target); + + // Skip null values silently + if (value == null) { + log.trace("Method '{}': Value at path '{}' is null, skipping key '{}'", + method.getName(), path, key); + return false; + } + + // Convert to string + String stringValue = converterResolver.convert(value, field); + + // Skip null converted values + if (stringValue == null) { + log.trace("Method '{}': Converted value for key '{}' is null, skipping", + method.getName(), key); + return false; + } + + // Add to baggage + baggageBuilder.put(key, stringValue); + + // Add to span attributes if enabled for this field (default: true) + if (field.addToSpanAttributes() && currentSpan.isRecording()) { + currentSpan.setAttribute(key, stringValue); + } + + log.trace("Method '{}': Added baggage key '{}' with value '{}'", + method.getName(), key, stringValue); + return true; + + } catch (Exception e) { + log.warn("Method '{}': Failed to process @BaggageField with key '{}' and path '{}': {}", + method.getName(), key, path, e.getMessage()); + if (log.isDebugEnabled()) { + log.debug("Baggage field processing error details", e); + } + return false; + } + } +} diff --git a/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java b/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java new file mode 100644 index 0000000..23989cb --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java @@ -0,0 +1,80 @@ +package dev.vality.otel.baggify.config; + +import dev.vality.otel.baggify.aspect.WithBaggageAspect; +import dev.vality.otel.baggify.converter.BaggageValueConverterResolver; +import dev.vality.otel.baggify.extractor.PathValueExtractor; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; + +/** + * Spring Boot Auto Configuration for otel-baggify library. + * + *

This configuration automatically sets up the necessary beans for + * {@link dev.vality.otel.baggify.annotation.WithBaggage} annotation processing: + *

+ * + *

Conditional Activation

+ *

This configuration is only activated when OpenTelemetry API classes are present + * on the classpath. + * + *

Customization

+ *

Each bean is created with {@link ConditionalOnMissingBean}, allowing users to + * provide their own implementations if needed. + */ +@AutoConfiguration +@EnableAspectJAutoProxy +@ConditionalOnClass(name = "io.opentelemetry.api.baggage.Baggage") +public class BaggifyAutoConfiguration { + + /** + * Creates the {@link PathValueExtractor} bean. + * + * @return the path value extractor + */ + @Bean + @ConditionalOnMissingBean + public PathValueExtractor pathValueExtractor() { + return new PathValueExtractor(); + } + + /** + * Creates the {@link BaggageValueConverterResolver} bean. + * + * @param beanFactory the Spring bean factory + * @param conversionService the Spring conversion service (optional) + * @return the converter resolver + */ + @Bean + @ConditionalOnMissingBean + public BaggageValueConverterResolver baggageValueConverterResolver( + BeanFactory beanFactory, + @Nullable ConversionService conversionService) { + return new BaggageValueConverterResolver(beanFactory, conversionService); + } + + /** + * Creates the {@link WithBaggageAspect} bean. + * + * @param pathValueExtractor the path value extractor + * @param converterResolver the converter resolver + * @return the aspect + */ + @Bean + @ConditionalOnMissingBean + public WithBaggageAspect withBaggageAspect( + PathValueExtractor pathValueExtractor, + BaggageValueConverterResolver converterResolver) { + return new WithBaggageAspect(pathValueExtractor, converterResolver); + } +} + diff --git a/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverter.java b/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverter.java new file mode 100644 index 0000000..1e810b4 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverter.java @@ -0,0 +1,74 @@ +package dev.vality.otel.baggify.converter; + +/** + * Functional interface for custom baggage value conversion. + * + *

Implementations transform extracted values into their String representation + * for storage in OpenTelemetry Baggage. + * + *

Usage

+ *
{@code
+ * public class UserIdConverter implements BaggageValueConverter {
+ *     @Override
+ *     public String convert(UserId value) {
+ *         return value != null ? value.getValue() : null;
+ *     }
+ * }
+ *
+ * // Usage in annotation
+ * @WithBaggage(@BaggageField(
+ *     key = "user.id",
+ *     path = "#userId",
+ *     converter = UserIdConverter.class
+ * ))
+ * public void process(UserId userId) { ... }
+ * }
+ * + *

Spring Bean Converter

+ *
{@code
+ * @Component("customConverter")
+ * public class CustomConverter implements BaggageValueConverter {
+ *     @Autowired
+ *     private SomeService service;
+ *
+ *     @Override
+ *     public String convert(MyType value) {
+ *         return service.format(value);
+ *     }
+ * }
+ *
+ * // Usage
+ * @BaggageField(key = "my.key", path = "#value", converterBean = "customConverter")
+ * }
+ * + * @param the type of value to convert + */ +@FunctionalInterface +public interface BaggageValueConverter { + + /** + * Converts the given value to a String for baggage storage. + * + * @param value the value to convert (may be null) + * @return the string representation, or null if value should be skipped + */ + String convert(T value); + + /** + * Default no-operation converter marker class. + *

+ * Used as the default value for {@link dev.vality.otel.baggify.annotation.BaggageField#converter()} + * to indicate that no custom converter is specified. + */ + final class NoOp implements BaggageValueConverter { + private NoOp() { + // Prevent instantiation - this is just a marker class + } + + @Override + public String convert(Object value) { + throw new UnsupportedOperationException("NoOp converter should never be invoked"); + } + } +} + diff --git a/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolver.java b/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolver.java new file mode 100644 index 0000000..5a48b25 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolver.java @@ -0,0 +1,156 @@ +package dev.vality.otel.baggify.converter; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; + +import dev.vality.otel.baggify.annotation.BaggageField; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolves the appropriate converter for baggage value conversion. + * + *

Conversion priority (R18): + *

    + *
  1. Custom converter specified via {@link BaggageField#converterBean()}
  2. + *
  3. Custom converter specified via {@link BaggageField#converter()}
  4. + *
  5. Spring {@link ConversionService} (if available and can convert)
  6. + *
  7. Direct String (no-op)
  8. + *
  9. {@link Object#toString()}
  10. + *
+ * + *

This class is designed to be fault-tolerant. Conversion failures + * result in null return values with warning logs, not exceptions. + */ +@Slf4j +public class BaggageValueConverterResolver { + + private final BeanFactory beanFactory; + private final ConversionService conversionService; + private final ConcurrentMap, BaggageValueConverter> converterCache = + new ConcurrentHashMap<>(); + + public BaggageValueConverterResolver(BeanFactory beanFactory, + @Nullable ConversionService conversionService) { + this.beanFactory = beanFactory; + this.conversionService = conversionService; + } + + /** + * Converts the given value to String using the appropriate converter. + *

+ * This method never throws exceptions. Conversion failures result in + * null return values with warning logs. + * + * @param value the value to convert (may be null) + * @param baggageField the baggage field annotation with converter configuration + * @return the string representation, or null if value should be skipped + */ + @SuppressWarnings("unchecked") + public String convert(@Nullable Object value, BaggageField baggageField) { + if (value == null) { + return null; + } + + String key = baggageField.key(); + + try { + // 1. Try converter bean first (highest priority when specified) + String converterBeanName = baggageField.converterBean(); + if (converterBeanName != null && !converterBeanName.isBlank()) { + return convertWithBean(value, converterBeanName, key); + } + + // 2. Try converter class + Class> converterClass = baggageField.converter(); + if (converterClass != BaggageValueConverter.NoOp.class) { + return convertWithClass(value, converterClass, key); + } + + // 3. Try Spring ConversionService + if (conversionService != null) { + try { + if (conversionService.canConvert(value.getClass(), String.class)) { + return conversionService.convert(value, String.class); + } + } catch (Exception e) { + log.trace("ConversionService failed for key '{}': {}", key, e.getMessage()); + // Fall through to next option + } + } + + // 4. Direct String (no-op) + if (value instanceof String stringValue) { + return stringValue; + } + + // 5. Fallback to toString() + return value.toString(); + + } catch (Exception e) { + log.warn("Failed to convert value for baggage key '{}': {}", key, e.getMessage()); + if (log.isDebugEnabled()) { + log.debug("Conversion error details for key '{}'", key, e); + } + return null; + } + } + + @SuppressWarnings("unchecked") + private String convertWithBean(Object value, String beanName, String key) { + try { + BaggageValueConverter converter = + (BaggageValueConverter) beanFactory.getBean( + beanName, BaggageValueConverter.class); + return converter.convert(value); + } catch (NoSuchBeanDefinitionException e) { + log.warn("Converter bean '{}' not found for baggage key '{}', " + + "falling back to default conversion", beanName, key); + return convertWithFallback(value); + } catch (Exception e) { + log.warn("Converter bean '{}' failed for baggage key '{}': {}, " + + "falling back to default conversion", beanName, key, e.getMessage()); + return convertWithFallback(value); + } + } + + @SuppressWarnings("unchecked") + private String convertWithClass(Object value, + Class> converterClass, + String key) { + try { + BaggageValueConverter converter = + (BaggageValueConverter) getOrCreateConverter(converterClass); + return converter.convert(value); + } catch (Exception e) { + log.warn("Converter class '{}' failed for baggage key '{}': {}, " + + "falling back to default conversion", + converterClass.getSimpleName(), key, e.getMessage()); + return convertWithFallback(value); + } + } + + private String convertWithFallback(Object value) { + if (value instanceof String stringValue) { + return stringValue; + } + return value.toString(); + } + + private BaggageValueConverter getOrCreateConverter( + Class> converterClass) { + return converterCache.computeIfAbsent(converterClass, clazz -> { + try { + return converterClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to instantiate converter: " + converterClass.getName() + + ". Ensure it has a public no-arg constructor.", e); + } + }); + } +} diff --git a/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java b/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java new file mode 100644 index 0000000..ac9dddd --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java @@ -0,0 +1,61 @@ +package dev.vality.otel.baggify.extractor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.expression.MethodBasedEvaluationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; + +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Extracts values from method invocation context using SpEL expressions. + * + *

Supports: + *

    + *
  • Method argument variables ({@code #paramName}, {@code #p0})
  • + *
  • Nested property access and null-safe navigation
  • + *
  • Root object access (target bean)
  • + *
+ * + *

This class is designed to be fault-tolerant. Invalid paths or + * missing values result in null return values with warning logs, + * not exceptions. + */ +@Slf4j +public class PathValueExtractor { + private final ExpressionParser expressionParser = new SpelExpressionParser(); + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + private final ConcurrentMap expressionCache = new ConcurrentHashMap<>(); + + /** + * Extracts a value from method arguments/root object using a SpEL expression. + * + * @param path SpEL expression + * @param method the method being invoked + * @param args method arguments + * @param rootObject root object for non-# expressions (e.g. target bean) + * @return extracted value or null when expression cannot be resolved + */ + public Object extractValue(String path, Method method, Object[] args, Object rootObject) { + try { + Object[] invocationArgs = args != null ? args : new Object[0]; + + var context = new MethodBasedEvaluationContext(rootObject, method, invocationArgs, parameterNameDiscoverer); + var expression = expressionCache.computeIfAbsent(path, expressionParser::parseExpression); + return expression.getValue(context); + + } catch (Exception e) { + log.warn("Failed to extract value at path '{}' from method '{}': {}", + path, method.getName(), e.getMessage()); + if (log.isDebugEnabled()) { + log.debug("Value extraction error details", e); + } + return null; + } + } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..2f5c1fc --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +dev.vality.otel.baggify.config.BaggifyAutoConfiguration + diff --git a/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java b/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java new file mode 100644 index 0000000..085ee9f --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java @@ -0,0 +1,507 @@ +package dev.vality.otel.baggify.aspect; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Service; + +import dev.vality.otel.baggify.annotation.BaggageField; +import dev.vality.otel.baggify.annotation.WithBaggage; +import dev.vality.otel.baggify.config.BaggifyAutoConfiguration; +import dev.vality.otel.baggify.converter.BaggageValueConverter; +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; + +@SpringBootTest(classes = WithBaggageAspectTest.TestConfig.class) +@DisplayName("WithBaggageAspect Integration Tests") +class WithBaggageAspectTest { + + @Autowired + private TestServiceBean testService; + + @Autowired + private InMemorySpanExporter spanExporter; + + @Autowired + private Tracer tracer; + + @BeforeEach + void setUp() { + spanExporter.reset(); + } + + @AfterEach + void tearDown() { + spanExporter.reset(); + } + + @Nested + @DisplayName("Basic baggage enrichment") + class BasicBaggageEnrichment { + + @Test + @DisplayName("Should enrich baggage with simple parameter") + void shouldEnrichBaggageWithSimpleParameter() { + AtomicReference capturedBaggage = new AtomicReference<>(); + + testService.simpleMethod("test-user-123", () -> { + capturedBaggage.set(Baggage.current().getEntryValue("user.id")); + }); + + assertThat(capturedBaggage.get()).isEqualTo("test-user-123"); + } + + @Test + @DisplayName("Should enrich baggage with multiple fields") + void shouldEnrichBaggageWithMultipleFields() { + AtomicReference capturedUserId = new AtomicReference<>(); + AtomicReference capturedOrderId = new AtomicReference<>(); + + testService.multipleFields("user-1", "order-99", () -> { + capturedUserId.set(Baggage.current().getEntryValue("user.id")); + capturedOrderId.set(Baggage.current().getEntryValue("order.id")); + }); + + assertThat(capturedUserId.get()).isEqualTo("user-1"); + assertThat(capturedOrderId.get()).isEqualTo("order-99"); + } + + @Test + @DisplayName("Should enrich baggage with nested field") + void shouldEnrichBaggageWithNestedField() { + AtomicReference capturedEmail = new AtomicReference<>(); + TestRequest request = new TestRequest(new TestUser("john@example.com")); + + testService.nestedField(request, () -> { + capturedEmail.set(Baggage.current().getEntryValue("user.email")); + }); + + assertThat(capturedEmail.get()).isEqualTo("john@example.com"); + } + + @Test + @DisplayName("Should enrich baggage using root object expression") + void shouldEnrichBaggageUsingRootObjectExpression() { + AtomicReference capturedTenant = new AtomicReference<>(); + + testService.rootExpression(() -> { + capturedTenant.set(Baggage.current().getEntryValue("tenant.id")); + }); + + assertThat(capturedTenant.get()).isEqualTo("tenant-42"); + } + } + + @Nested + @DisplayName("Null value handling") + class NullValueHandling { + + @Test + @DisplayName("Should skip null values without error") + void shouldSkipNullValuesWithoutError() { + AtomicReference capturedBaggage = new AtomicReference<>(); + + testService.simpleMethod(null, () -> { + capturedBaggage.set(Baggage.current().getEntryValue("user.id")); + }); + + assertThat(capturedBaggage.get()).isNull(); + } + + @Test + @DisplayName("Should skip null nested values without error") + void shouldSkipNullNestedValuesWithoutError() { + AtomicReference capturedEmail = new AtomicReference<>(); + TestRequest request = new TestRequest(null); + + testService.nestedField(request, () -> { + capturedEmail.set(Baggage.current().getEntryValue("user.email")); + }); + + assertThat(capturedEmail.get()).isNull(); + } + } + + @Nested + @DisplayName("Context restoration") + class ContextRestoration { + + @Test + @DisplayName("Should restore original baggage after method execution") + void shouldRestoreOriginalBaggageAfterMethodExecution() { + // Set up initial baggage + Baggage initialBaggage = Baggage.builder() + .put("initial.key", "initial-value") + .build(); + + AtomicReference insideBaggage = new AtomicReference<>(); + AtomicReference outsideBaggage = new AtomicReference<>(); + + try (Scope ignored = initialBaggage.makeCurrent()) { + testService.simpleMethod("method-value", () -> { + insideBaggage.set(Baggage.current().getEntryValue("user.id")); + }); + + // After method - should have original baggage + outsideBaggage.set(Baggage.current().getEntryValue("user.id")); + } + + assertThat(insideBaggage.get()).isEqualTo("method-value"); + assertThat(outsideBaggage.get()).isNull(); + } + + @Test + @DisplayName("Should restore context even when exception is thrown") + void shouldRestoreContextEvenWhenExceptionIsThrown() { + AtomicReference afterException = new AtomicReference<>(); + + try { + testService.throwingMethod("user-123"); + } catch (RuntimeException e) { + // Expected + } + + // Baggage should be restored (not contain the method's value) + afterException.set(Baggage.current().getEntryValue("user.id")); + assertThat(afterException.get()).isNull(); + } + } + + @Nested + @DisplayName("Span attributes") + class SpanAttributes { + + @Test + @DisplayName("Should add to span attributes by default") + void shouldAddToSpanAttributesByDefault() { + Span span = tracer.spanBuilder("test-span").startSpan(); + try (Scope ignored = span.makeCurrent()) { + // simpleMethod uses default addToSpanAttributes=true + testService.simpleMethod("user-attr-123", () -> { + assertThat(Baggage.current().getEntryValue("user.id")).isEqualTo("user-attr-123"); + }); + } finally { + span.end(); + } + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + + SpanData spanData = spans.get(0); + assertThat(spanData.getAttributes().asMap()) + .containsEntry(io.opentelemetry.api.common.AttributeKey.stringKey("user.id"), "user-attr-123"); + } + + @Test + @DisplayName("Should NOT add to span attributes when disabled on field") + void shouldNotAddToSpanAttributesWhenDisabled() { + Span span = tracer.spanBuilder("test-span").startSpan(); + try (Scope ignored = span.makeCurrent()) { + testService.withoutSpanAttributes("baggage-only-value", () -> { + // Value should still be in baggage + assertThat(Baggage.current().getEntryValue("baggage.only")).isEqualTo("baggage-only-value"); + }); + } finally { + span.end(); + } + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + + SpanData spanData = spans.get(0); + // Should NOT contain the attribute + assertThat(spanData.getAttributes().asMap()) + .doesNotContainKey(io.opentelemetry.api.common.AttributeKey.stringKey("baggage.only")); + } + + @Test + @DisplayName("Should handle mixed span attribute settings per field") + void shouldHandleMixedSpanAttributeSettings() { + Span span = tracer.spanBuilder("test-span").startSpan(); + try (Scope ignored = span.makeCurrent()) { + testService.mixedSpanAttributes("visible", "hidden", () -> { + assertThat(Baggage.current().getEntryValue("span.visible")).isEqualTo("visible"); + assertThat(Baggage.current().getEntryValue("span.hidden")).isEqualTo("hidden"); + }); + } finally { + span.end(); + } + + List spans = spanExporter.getFinishedSpanItems(); + SpanData spanData = spans.get(0); + + // Only "span.visible" should be in attributes + assertThat(spanData.getAttributes().asMap()) + .containsEntry(io.opentelemetry.api.common.AttributeKey.stringKey("span.visible"), "visible") + .doesNotContainKey(io.opentelemetry.api.common.AttributeKey.stringKey("span.hidden")); + } + + @Test + @DisplayName("Should not fail when no active span exists") + void shouldNotFailWhenNoActiveSpanExists() { + AtomicReference capturedBaggage = new AtomicReference<>(); + + // No span context - should still work for baggage + testService.simpleMethod("user-no-span", () -> { + capturedBaggage.set(Baggage.current().getEntryValue("user.id")); + }); + + assertThat(capturedBaggage.get()).isEqualTo("user-no-span"); + } + } + + @Nested + @DisplayName("Custom converters") + class CustomConverters { + + @Test + @DisplayName("Should use custom converter class") + void shouldUseCustomConverterClass() { + AtomicReference capturedBaggage = new AtomicReference<>(); + + testService.withCustomConverter(123, () -> { + capturedBaggage.set(Baggage.current().getEntryValue("custom.value")); + }); + + assertThat(capturedBaggage.get()).isEqualTo("custom:123"); + } + + @Test + @DisplayName("Should use converter bean") + void shouldUseConverterBean() { + AtomicReference capturedBaggage = new AtomicReference<>(); + + testService.withConverterBean("input-value", () -> { + capturedBaggage.set(Baggage.current().getEntryValue("bean.value")); + }); + + assertThat(capturedBaggage.get()).isEqualTo("bean-converted:input-value"); + } + } + + @Nested + @DisplayName("Error resilience - business logic should never break") + class ErrorResilience { + + @Test + @DisplayName("Should execute method even with duplicate keys (just log warning)") + void shouldExecuteMethodEvenWithDuplicateKeys() { + AtomicReference methodExecuted = new AtomicReference<>(false); + + // Should NOT throw exception, just log warning + testService.duplicateKeys("a", "b", () -> { + methodExecuted.set(true); + }); + + assertThat(methodExecuted.get()).isTrue(); + } + + @Test + @DisplayName("Should execute method even with invalid path") + void shouldExecuteMethodEvenWithInvalidPath() { + AtomicReference methodExecuted = new AtomicReference<>(false); + + testService.invalidPath("value", () -> { + methodExecuted.set(true); + }); + + assertThat(methodExecuted.get()).isTrue(); + } + + @Test + @DisplayName("Should execute method even when converter fails") + void shouldExecuteMethodEvenWhenConverterFails() { + AtomicReference methodExecuted = new AtomicReference<>(false); + + testService.withFailingConverter("value", () -> { + methodExecuted.set(true); + }); + + assertThat(methodExecuted.get()).isTrue(); + } + } + + // Test configuration + + @Configuration + @EnableAspectJAutoProxy + @Import(BaggifyAutoConfiguration.class) + static class TestConfig { + + @Bean + public InMemorySpanExporter spanExporter() { + return InMemorySpanExporter.create(); + } + + @Bean + public SdkTracerProvider tracerProvider(InMemorySpanExporter spanExporter) { + return SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + } + + @Bean + public OpenTelemetrySdk openTelemetry(SdkTracerProvider tracerProvider) { + return OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .build(); + } + + @Bean + public Tracer tracer(OpenTelemetrySdk openTelemetry) { + return openTelemetry.getTracer("test-tracer"); + } + + @Bean + public TestServiceBean testServiceBean() { + return new TestServiceBean(); + } + + @Bean("testConverterBean") + public BaggageValueConverter testConverterBean() { + return value -> "bean-converted:" + value; + } + } + + // Test service + + @Service + public static class TestServiceBean { + private final String defaultTenantId = "tenant-42"; + + public String getDefaultTenantId() { + return defaultTenantId; + } + + @WithBaggage(@BaggageField(key = "user.id", path = "#userId")) + public void simpleMethod(String userId, Runnable callback) { + callback.run(); + } + + @WithBaggage({ + @BaggageField(key = "user.id", path = "#userId"), + @BaggageField(key = "order.id", path = "#orderId") + }) + public void multipleFields(String userId, String orderId, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "user.email", path = "#request.user.email")) + public void nestedField(TestRequest request, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "user.id", path = "#userId")) + public void throwingMethod(String userId) { + throw new RuntimeException("Test exception"); + } + + @WithBaggage(@BaggageField(key = "baggage.only", path = "#value", addToSpanAttributes = false)) + public void withoutSpanAttributes(String value, Runnable callback) { + callback.run(); + } + + @WithBaggage({ + @BaggageField(key = "span.visible", path = "#visible"), + @BaggageField(key = "span.hidden", path = "#hidden", addToSpanAttributes = false) + }) + public void mixedSpanAttributes(String visible, String hidden, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "custom.value", path = "#value", + converter = CustomTestConverter.class)) + public void withCustomConverter(Integer value, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "bean.value", path = "#value", + converterBean = "testConverterBean")) + public void withConverterBean(String value, Runnable callback) { + callback.run(); + } + + @WithBaggage({ + @BaggageField(key = "same.key", path = "#value1"), + @BaggageField(key = "same.key", path = "#value2") + }) + public void duplicateKeys(String value1, String value2, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "test.key", path = "invalidPath")) + public void invalidPath(String value, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "test.key", path = "#value", + converter = FailingConverter.class)) + public void withFailingConverter(String value, Runnable callback) { + callback.run(); + } + + @WithBaggage(@BaggageField(key = "tenant.id", path = "getDefaultTenantId()")) + public void rootExpression(Runnable callback) { + callback.run(); + } + } + + // Test classes + + public static class TestRequest { + private final TestUser user; + + public TestRequest(TestUser user) { + this.user = user; + } + + public TestUser getUser() { + return user; + } + } + + public static class TestUser { + private final String email; + + public TestUser(String email) { + this.email = email; + } + + public String getEmail() { + return email; + } + } + + public static class CustomTestConverter implements BaggageValueConverter { + @Override + public String convert(Integer value) { + return "custom:" + value; + } + } + + public static class FailingConverter implements BaggageValueConverter { + @Override + public String convert(Object value) { + throw new RuntimeException("Converter failed intentionally"); + } + } +} diff --git a/src/test/java/dev/vality/otel/baggify/config/BaggifyAutoConfigurationTest.java b/src/test/java/dev/vality/otel/baggify/config/BaggifyAutoConfigurationTest.java new file mode 100644 index 0000000..24c27d2 --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/config/BaggifyAutoConfigurationTest.java @@ -0,0 +1,65 @@ +package dev.vality.otel.baggify.config; + +import dev.vality.otel.baggify.aspect.WithBaggageAspect; +import dev.vality.otel.baggify.converter.BaggageValueConverterResolver; +import dev.vality.otel.baggify.extractor.PathValueExtractor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("BaggifyAutoConfiguration Tests") +class BaggifyAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(BaggifyAutoConfiguration.class)); + + @Test + @DisplayName("Should create all beans when OpenTelemetry is on classpath") + void shouldCreateAllBeansWhenOpenTelemetryIsOnClasspath() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(PathValueExtractor.class); + assertThat(context).hasSingleBean(BaggageValueConverterResolver.class); + assertThat(context).hasSingleBean(WithBaggageAspect.class); + }); + } + + @Test + @DisplayName("Should allow custom PathValueExtractor bean") + void shouldAllowCustomPathValueExtractorBean() { + PathValueExtractor customExtractor = new PathValueExtractor(); + + contextRunner + .withBean(PathValueExtractor.class, () -> customExtractor) + .run(context -> { + assertThat(context).hasSingleBean(PathValueExtractor.class); + assertThat(context.getBean(PathValueExtractor.class)).isSameAs(customExtractor); + }); + } + + @Test + @DisplayName("Should allow custom BaggageValueConverterResolver bean") + void shouldAllowCustomBaggageValueConverterResolverBean() { + contextRunner + .withBean(BaggageValueConverterResolver.class, () -> + new BaggageValueConverterResolver(null, null)) + .run(context -> { + assertThat(context).hasSingleBean(BaggageValueConverterResolver.class); + }); + } + + @Test + @DisplayName("Should allow custom WithBaggageAspect bean") + void shouldAllowCustomWithBaggageAspectBean() { + contextRunner + .withBean(WithBaggageAspect.class, () -> + new WithBaggageAspect(new PathValueExtractor(), + new BaggageValueConverterResolver(null, null))) + .run(context -> { + assertThat(context).hasSingleBean(WithBaggageAspect.class); + }); + } +} + diff --git a/src/test/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolverTest.java b/src/test/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolverTest.java new file mode 100644 index 0000000..b93a2c7 --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolverTest.java @@ -0,0 +1,242 @@ +package dev.vality.otel.baggify.converter; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.core.convert.ConversionService; + +import dev.vality.otel.baggify.annotation.BaggageField; + +@DisplayName("BaggageValueConverterResolver") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BaggageValueConverterResolverTest { + + @Mock + private BeanFactory beanFactory; + + @Mock + private ConversionService conversionService; + + private BaggageValueConverterResolver resolver; + + @BeforeEach + void setUp() { + resolver = new BaggageValueConverterResolver(beanFactory, conversionService); + } + + @Nested + @DisplayName("Null handling") + class NullHandling { + + @Test + @DisplayName("Should return null for null value") + void shouldReturnNullForNullValue() { + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, ""); + + String result = resolver.convert(null, field); + + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("Converter bean priority") + class ConverterBeanPriority { + + @Test + @DisplayName("Should use converter bean when specified") + @SuppressWarnings("unchecked") + void shouldUseConverterBeanWhenSpecified() { + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, "customConverter"); + BaggageValueConverter mockConverter = mock(BaggageValueConverter.class); + when(beanFactory.getBean("customConverter", BaggageValueConverter.class)).thenReturn(mockConverter); + when(mockConverter.convert("test")).thenReturn("converted-by-bean"); + + String result = resolver.convert("test", field); + + assertThat(result).isEqualTo("converted-by-bean"); + } + } + + @Nested + @DisplayName("Converter class priority") + class ConverterClassPriority { + + @Test + @DisplayName("Should use converter class when specified") + void shouldUseConverterClassWhenSpecified() { + BaggageField field = mockBaggageField("key", "#path", TestConverter.class, ""); + + String result = resolver.convert(42, field); + + assertThat(result).isEqualTo("test-converted:42"); + } + + @Test + @DisplayName("Should cache converter instances") + void shouldCacheConverterInstances() { + // Reset counter before test + CountingConverter.instanceCount = 0; + BaggageField field = mockBaggageField("key", "#path", CountingConverter.class, ""); + + resolver.convert("value1", field); + resolver.convert("value2", field); + + // If instances were cached, counter should be 1 (same instance used twice) + assertThat(CountingConverter.instanceCount).isEqualTo(1); + } + } + + @Nested + @DisplayName("ConversionService priority") + class ConversionServicePriority { + + @Test + @DisplayName("Should use ConversionService when no custom converter") + void shouldUseConversionServiceWhenNoCustomConverter() { + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, ""); + UUID uuid = UUID.randomUUID(); + when(conversionService.canConvert(UUID.class, String.class)).thenReturn(true); + when(conversionService.convert(uuid, String.class)).thenReturn("uuid-converted"); + + String result = resolver.convert(uuid, field); + + assertThat(result).isEqualTo("uuid-converted"); + } + } + + @Nested + @DisplayName("String passthrough") + class StringPassthrough { + + @Test + @DisplayName("Should return String value as-is when no converter") + void shouldReturnStringValueAsIs() { + resolver = new BaggageValueConverterResolver(beanFactory, null); // No ConversionService + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, ""); + + String result = resolver.convert("direct-string", field); + + assertThat(result).isEqualTo("direct-string"); + } + } + + @Nested + @DisplayName("ToString fallback") + class ToStringFallback { + + @Test + @DisplayName("Should use toString() as last resort") + void shouldUseToStringAsLastResort() { + resolver = new BaggageValueConverterResolver(beanFactory, null); // No ConversionService + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, ""); + TestObject obj = new TestObject("test-value"); + + String result = resolver.convert(obj, field); + + assertThat(result).isEqualTo("TestObject[test-value]"); + } + } + + @Nested + @DisplayName("Error handling - graceful fallback") + class ErrorHandling { + + @Test + @DisplayName("Should fallback to toString when converter has no no-arg constructor") + void shouldFallbackToToStringWhenConverterHasNoNoArgConstructor() { + BaggageField field = mockBaggageField("key", "#path", ConverterWithoutNoArgConstructor.class, ""); + + // Should NOT throw exception, instead fallback to toString + String result = resolver.convert("test-value", field); + + assertThat(result).isEqualTo("test-value"); + } + + @Test + @DisplayName("Should fallback when converter bean not found") + void shouldFallbackWhenConverterBeanNotFound() { + BaggageField field = mockBaggageField("key", "#path", BaggageValueConverter.NoOp.class, "nonExistentBean"); + when(beanFactory.getBean("nonExistentBean", BaggageValueConverter.class)) + .thenThrow(new org.springframework.beans.factory.NoSuchBeanDefinitionException("nonExistentBean")); + + // Should NOT throw exception, instead fallback to toString + String result = resolver.convert("fallback-value", field); + + assertThat(result).isEqualTo("fallback-value"); + } + } + + // Helper method to create mock BaggageField with lenient stubbing + @SuppressWarnings("unchecked") + private BaggageField mockBaggageField(String key, String path, + Class> converter, + String converterBean) { + BaggageField field = mock(BaggageField.class); + lenient().when(field.key()).thenReturn(key); + lenient().when(field.path()).thenReturn(path); + lenient().doReturn(converter).when(field).converter(); + lenient().when(field.converterBean()).thenReturn(converterBean); + return field; + } + + // Test converter classes + + public static class TestConverter implements BaggageValueConverter { + @Override + public String convert(Object value) { + return "test-converted:" + value; + } + } + + public static class CountingConverter implements BaggageValueConverter { + static int instanceCount = 0; + + public CountingConverter() { + instanceCount++; + } + + @Override + public String convert(Object value) { + return value.toString(); + } + } + + public static class ConverterWithoutNoArgConstructor implements BaggageValueConverter { + @SuppressWarnings("unused") + public ConverterWithoutNoArgConstructor(String required) { + } + + @Override + public String convert(Object value) { + return value.toString(); + } + } + + public static class TestObject { + private final String value; + + public TestObject(String value) { + this.value = value; + } + + @Override + public String toString() { + return "TestObject[" + value + "]"; + } + } +} diff --git a/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java new file mode 100644 index 0000000..daceee7 --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java @@ -0,0 +1,275 @@ +package dev.vality.otel.baggify.extractor; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PathValueExtractor") +class PathValueExtractorTest { + + private PathValueExtractor extractor; + + @BeforeEach + void setUp() { + extractor = new PathValueExtractor(); + } + + @Nested + @DisplayName("Path validation - returns null instead of throwing") + class PathValidation { + + @Test + @DisplayName("Should return null for null path") + void shouldReturnNullForNullPath() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue(null, method, new Object[]{"value"}, null); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should return null for blank path") + void shouldReturnNullForBlankPath() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue(" ", method, new Object[]{"value"}, null); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should return null for path without # prefix") + void shouldReturnNullForPathWithoutHashPrefix() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue("userId", method, new Object[]{"value"}, null); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should return null for path with only #") + void shouldReturnNullForPathWithOnlyHash() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue("#", method, new Object[]{"value"}, null); + + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("Simple parameter extraction") + class SimpleParameterExtraction { + + @Test + @DisplayName("Should extract String parameter") + void shouldExtractStringParameter() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue("#userId", method, new Object[]{"test-user"}, null); + + assertThat(result).isEqualTo("test-user"); + } + + @Test + @DisplayName("Should extract Integer parameter") + void shouldExtractIntegerParameter() throws Exception { + Method method = TestService.class.getMethod("methodWithInteger", Integer.class); + + Object result = extractor.extractValue("#count", method, new Object[]{42}, null); + + assertThat(result).isEqualTo(42); + } + + @Test + @DisplayName("Should return null for null parameter") + void shouldReturnNullForNullParameter() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue("#userId", method, new Object[]{null}, null); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should return null for unknown parameter (no exception)") + void shouldReturnNullForUnknownParameter() throws Exception { + Method method = TestService.class.getMethod("simpleMethod", String.class); + + Object result = extractor.extractValue("#unknown", method, new Object[]{"value"}, null); + + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("Nested field extraction") + class NestedFieldExtraction { + + @Test + @DisplayName("Should extract nested field via getter") + void shouldExtractNestedFieldViaGetter() throws Exception { + Method method = TestService.class.getMethod("methodWithRequest", TestRequest.class); + TestRequest request = new TestRequest("user-123", new TestUser("John", "john@test.com")); + + Object result = extractor.extractValue("#request.userId", method, new Object[]{request}, null); + + assertThat(result).isEqualTo("user-123"); + } + + @Test + @DisplayName("Should extract deeply nested field") + void shouldExtractDeeplyNestedField() throws Exception { + Method method = TestService.class.getMethod("methodWithRequest", TestRequest.class); + TestRequest request = new TestRequest("user-123", new TestUser("John", "john@test.com")); + + Object result = extractor.extractValue("#request.user.email", method, new Object[]{request}, null); + + assertThat(result).isEqualTo("john@test.com"); + } + + @Test + @DisplayName("Should return null when intermediate field is null") + void shouldReturnNullWhenIntermediateFieldIsNull() throws Exception { + Method method = TestService.class.getMethod("methodWithRequest", TestRequest.class); + TestRequest request = new TestRequest("user-123", null); + + Object result = extractor.extractValue("#request.user.email", method, new Object[]{request}, null); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should extract field from record") + void shouldExtractFieldFromRecord() throws Exception { + Method method = TestService.class.getMethod("methodWithRecord", TestRecord.class); + TestRecord record = new TestRecord("record-id", "Record Name"); + + Object result = extractor.extractValue("#record.id", method, new Object[]{record}, null); + + assertThat(result).isEqualTo("record-id"); + } + + @Test + @DisplayName("Should return null for non-existent field (no exception)") + void shouldReturnNullForNonExistentField() throws Exception { + Method method = TestService.class.getMethod("methodWithRequest", TestRequest.class); + TestRequest request = new TestRequest("user-123", new TestUser("John", "john@test.com")); + + Object result = extractor.extractValue("#request.nonExistent", method, new Object[]{request}, null); + + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("Multiple parameters") + class MultipleParameters { + + @Test + @DisplayName("Should extract correct parameter from multiple") + void shouldExtractCorrectParameterFromMultiple() throws Exception { + Method method = TestService.class.getMethod("multipleParams", String.class, Integer.class, String.class); + + Object result = extractor.extractValue("#name", method, new Object[]{"id-1", 42, "John"}, null); + + assertThat(result).isEqualTo("John"); + } + } + + @Nested + @DisplayName("Root object expressions") + class RootObjectExpressions { + + @Test + @DisplayName("Should extract value from root object") + void shouldExtractValueFromRootObject() throws Exception { + Method method = TestService.class.getMethod("noArgsMethod"); + RootObject root = new RootObject("tenant-42"); + + Object result = extractor.extractValue("tenantId", method, new Object[0], root); + + assertThat(result).isEqualTo("tenant-42"); + } + } + + // Test classes and interfaces + + @SuppressWarnings("unused") + public static class TestService { + + public void simpleMethod(String userId) { + } + + public void methodWithInteger(Integer count) { + } + + public void methodWithRequest(TestRequest request) { + } + + public void methodWithRecord(TestRecord record) { + } + + public void multipleParams(String id, Integer count, String name) { + } + + public void noArgsMethod() { + } + } + + public static class TestRequest { + private final String userId; + private final TestUser user; + + public TestRequest(String userId, TestUser user) { + this.userId = userId; + this.user = user; + } + + public String getUserId() { + return userId; + } + + public TestUser getUser() { + return user; + } + } + + public static class TestUser { + private final String name; + private final String email; + + public TestUser(String name, String email) { + this.name = name; + this.email = email; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + } + + public record TestRecord(String id, String name) {} + + public static class RootObject { + private final String tenantId; + + public RootObject(String tenantId) { + this.tenantId = tenantId; + } + + public String getTenantId() { + return tenantId; + } + } +}