From e8a71d3ce5a2e960ead77d7116b2ccccfc3555ea Mon Sep 17 00:00:00 2001 From: "f.shim" Date: Thu, 29 Jan 2026 22:33:41 +0300 Subject: [PATCH 1/3] init commit --- .github/workflows/build.yml | 11 + .github/workflows/deploy.yml | 19 + .gitignore | 53 ++ README.md | 226 +++++++- pom.xml | 197 +++++++ .../otel/baggify/annotation/BaggageField.java | 103 ++++ .../otel/baggify/annotation/WithBaggage.java | 71 +++ .../baggify/aspect/WithBaggageAspect.java | 246 +++++++++ .../config/BaggifyAutoConfiguration.java | 80 +++ .../converter/BaggageValueConverter.java | 74 +++ .../BaggageValueConverterResolver.java | 156 ++++++ .../baggify/extractor/PathValueExtractor.java | 177 +++++++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../baggify/aspect/WithBaggageAspectTest.java | 485 ++++++++++++++++++ .../config/BaggifyAutoConfigurationTest.java | 65 +++ .../BaggageValueConverterResolverTest.java | 242 +++++++++ .../extractor/PathValueExtractorTest.java | 234 +++++++++ 17 files changed, 2440 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java create mode 100644 src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java create mode 100644 src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java create mode 100644 src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java create mode 100644 src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverter.java create mode 100644 src/main/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolver.java create mode 100644 src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java create mode 100644 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java create mode 100644 src/test/java/dev/vality/otel/baggify/config/BaggifyAutoConfigurationTest.java create mode 100644 src/test/java/dev/vality/otel/baggify/converter/BaggageValueConverterResolverTest.java create mode 100644 src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java 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..a868c37 100644 --- a/README.md +++ b/README.md @@ -1 +1,225 @@ -# 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 конвертера | + +## Синтаксис путей + +Путь должен начинаться с `#` и имени параметра метода: + +``` +#userId // Параметр целиком +#request.userId // request.getUserId() +#order.customer.email // order.getCustomer().getEmail() +``` + +> **Примечание:** Работает из коробки со Spring Boot. Если имена параметров не распознаются, добавьте в maven-compiler-plugin опцию `true`. + +## Конвертация значений + +Порядок выбора механизма конвертации (от высшего приоритета к низшему): + +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` +- `path` не начинается с `#` +- Параметр с указанным именем не найден +- Дублирующиеся ключи в одной аннотации +- Ошибка в кастомном конвертере + +``` +WARN WithBaggageAspect : Method 'process': @BaggageField path 'userId' for key 'user.id' must start with '#', skipping +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..5840dd1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,197 @@ + + + 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 + + + + ./src/main/resources/checkstyle/checkstyle-suppressions.xml + + + + + + + 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 + + + + + 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..7d1d02f --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java @@ -0,0 +1,103 @@ +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(); + + /** + * The path to extract the value from method arguments. + *

+ * Must start with {@code #} followed by the parameter name. + * Supports dot-notation for nested field access. + * + *

Examples: + *

    + *
  • {@code #userId} - extracts the userId parameter directly
  • + *
  • {@code #request.user.id} - navigates to request.getUser().getId()
  • + *
  • {@code #order.customer.email} - nested field access
  • + *
+ * + *

Note: Parameter names must 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): + *

    + *
  • Value is written to both Baggage and Span Attributes
  • + *
  • If no active span exists, only Baggage is enriched (no error)
  • + *
+ *

+ * When {@code false}: + *

    + *
  • Value is written only to Baggage
  • + *
+ * + * @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..88232e3 --- /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

+ *
    + *
  • Method parameter names must be available at runtime (compile with {@code -parameters})
  • + *
  • Each {@code key} within a single annotation must be unique
  • + *
  • Each {@code path} must start with {@code #parameterName}
  • + *
+ * + * @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..2925c0b --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java @@ -0,0 +1,246 @@ +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

+ *
    + *
  • Extracts values from method arguments using path expressions
  • + *
  • Enriches OpenTelemetry Baggage with extracted values
  • + *
  • Optionally writes values to current Span Attributes
  • + *
  • Restores original context after method execution
  • + *
+ * + *

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(); + + // 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, 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; + } + + if (!path.startsWith("#")) { + log.warn("Method '{}': @BaggageField path '{}' for key '{}' must start with '#', skipping", + methodName, path, 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, + BaggageBuilder baggageBuilder, Span currentSpan) { + String key = field.key(); + String path = field.path(); + + try { + // Extract value using path + Object value = pathValueExtractor.extractValue(path, method, args); + + // 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: + *

    + *
  • {@link PathValueExtractor} - for extracting values from method arguments
  • + *
  • {@link BaggageValueConverterResolver} - for value conversion
  • + *
  • {@link WithBaggageAspect} - the AOP aspect that processes annotations
  • + *
+ * + *

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..c478076 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java @@ -0,0 +1,177 @@ +package dev.vality.otel.baggify.extractor; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.HashMap; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +/** + * Extracts values from method arguments using path expressions. + * + *

Supports: + *

    + *
  • Direct parameter access: {@code #paramName}
  • + *
  • Nested field access via dot-notation: {@code #paramName.field.subfield}
  • + *
  • JavaBean getters and record accessors
  • + *
+ * + *

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 static final String PATH_PREFIX = "#"; + private static final String PATH_SEPARATOR = "\\."; + + /** + * Extracts a value from method arguments using the given path expression. + *

+ * This method never throws exceptions. Invalid paths or extraction + * failures result in null return values with warning logs. + * + * @param path the path expression (e.g., "#userId" or "#request.user.id") + * @param method the method being invoked + * @param args the method arguments + * @return the extracted value, or null if not found or path is invalid + */ + public Object extractValue(String path, Method method, Object[] args) { + // Validate path + if (path == null || path.isBlank()) { + log.warn("Path is null or blank"); + return null; + } + + if (!path.startsWith(PATH_PREFIX)) { + log.warn("Path '{}' must start with '#'", path); + return null; + } + + if (path.length() <= 1) { + log.warn("Path '{}' must specify a parameter name after '#'", path); + return null; + } + + try { + String normalizedPath = path.substring(PATH_PREFIX.length()); + String[] pathParts = normalizedPath.split(PATH_SEPARATOR); + String parameterName = pathParts[0]; + + // Find parameter index by name + int paramIndex = findParameterIndex(method, parameterName); + if (paramIndex < 0) { + log.warn("Parameter '{}' not found in method '{}'. " + + "Ensure the parameter exists and code is compiled with -parameters flag.", + parameterName, method.getName()); + return null; + } + + if (paramIndex >= args.length) { + log.warn("Parameter index {} out of bounds for method '{}' with {} arguments", + paramIndex, method.getName(), args.length); + return null; + } + + Object value = args[paramIndex]; + + // Navigate nested path + for (int i = 1; i < pathParts.length && value != null; i++) { + value = getFieldValue(value, pathParts[i]); + } + + return value; + + } 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; + } + } + + /** + * Builds a map of parameter names to their values for the given method invocation. + * + * @param method the method being invoked + * @param args the method arguments + * @return map of parameter name to value + */ + public Map buildParameterMap(Method method, Object[] args) { + Map parameterMap = new HashMap<>(); + Parameter[] parameters = method.getParameters(); + + for (int i = 0; i < parameters.length && i < args.length; i++) { + if (parameters[i].isNamePresent()) { + parameterMap.put(parameters[i].getName(), args[i]); + } + } + + return parameterMap; + } + + private int findParameterIndex(Method method, String parameterName) { + Parameter[] parameters = method.getParameters(); + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].isNamePresent() + && parameters[i].getName().equals(parameterName)) { + return i; + } + } + return -1; + } + + private Object getFieldValue(Object target, String fieldName) { + if (target == null) { + return null; + } + + Class clazz = target.getClass(); + + // Try getter method (getXxx or isXxx for boolean) + String capitalizedName = capitalize(fieldName); + try { + Method getter = clazz.getMethod("get" + capitalizedName); + return getter.invoke(target); + } catch (NoSuchMethodException e) { + // Try boolean getter + try { + Method booleanGetter = clazz.getMethod("is" + capitalizedName); + return booleanGetter.invoke(target); + } catch (NoSuchMethodException ex) { + // Try record accessor (field name as method name) + try { + Method accessor = clazz.getMethod(fieldName); + return accessor.invoke(target); + } catch (NoSuchMethodException exc) { + log.trace("No accessor found for field '{}' on class '{}'", + fieldName, clazz.getSimpleName()); + return null; + } catch (Exception exc) { + log.trace("Failed to invoke accessor '{}' on class '{}': {}", + fieldName, clazz.getSimpleName(), exc.getMessage()); + return null; + } + } catch (Exception ex) { + log.trace("Failed to invoke boolean getter for '{}' on class '{}': {}", + fieldName, clazz.getSimpleName(), ex.getMessage()); + return null; + } + } catch (Exception e) { + log.trace("Failed to invoke getter for '{}' on class '{}': {}", + fieldName, clazz.getSimpleName(), e.getMessage()); + return null; + } + } + + private String capitalize(String str) { + if (str == null || str.isEmpty()) { + return str; + } + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } +} 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..cf8d943 --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java @@ -0,0 +1,485 @@ +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"); + } + } + + @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 { + + @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(); + } + } + + // 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..b94f3d8 --- /dev/null +++ b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java @@ -0,0 +1,234 @@ +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"}); + + 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"}); + + 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"}); + + 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"}); + + 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"}); + + 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}); + + 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}); + + 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"}); + + 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}); + + 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}); + + 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}); + + 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}); + + 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}); + + 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"}); + + assertThat(result).isEqualTo("John"); + } + } + + // 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 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) {} +} From 2f06de6c8595887ac369090f18523731e652069a Mon Sep 17 00:00:00 2001 From: "f.shim" Date: Thu, 29 Jan 2026 22:40:01 +0300 Subject: [PATCH 2/3] fix --- pom.xml | 12 +++++++---- .../extractor/PathValueExtractorTest.java | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index 5840dd1..bbf7acc 100644 --- a/pom.xml +++ b/pom.xml @@ -46,10 +46,6 @@ 1.35.0 2.1.0 - - - ./src/main/resources/checkstyle/checkstyle-suppressions.xml - @@ -99,6 +95,14 @@ slf4j-api + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + org.springframework.boot diff --git a/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java index b94f3d8..c43d161 100644 --- a/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java +++ b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java @@ -187,11 +187,21 @@ void shouldExtractCorrectParameterFromMultiple() throws Exception { @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 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 static class TestRequest { From 9b41d69e78b5b3e1657da1e6461249b772941dee Mon Sep 17 00:00:00 2001 From: "f.shim" Date: Mon, 30 Mar 2026 14:05:04 +0700 Subject: [PATCH 3/3] refactoring PathValueExtractor --- README.md | 19 +- .../otel/baggify/annotation/BaggageField.java | 17 +- .../otel/baggify/annotation/WithBaggage.java | 2 +- .../baggify/aspect/WithBaggageAspect.java | 13 +- .../baggify/extractor/PathValueExtractor.java | 172 +++--------------- .../baggify/aspect/WithBaggageAspectTest.java | 22 +++ .../extractor/PathValueExtractorTest.java | 59 ++++-- 7 files changed, 122 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index a868c37..b24034f 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,20 @@ public void tracedOperation(String userId, String traceId) { | `converter` | `Class>` | `NoOp` | Класс кастомного конвертера | | `converterBean` | `String` | `""` | Имя Spring Bean конвертера | -## Синтаксис путей +## Синтаксис выражений -Путь должен начинаться с `#` и имени параметра метода: +`path` — это SpEL-выражение. Поддерживаются: ``` -#userId // Параметр целиком -#request.userId // request.getUserId() -#order.customer.email // order.getCustomer().getEmail() +#userId // параметр по имени +#request.userId // вложенное свойство аргумента +#order.customer?.email // null-safe навигация +#p0 // параметр по индексу +defaultTenantId // свойство target-объекта (root object) +getDefaultTenantId() // метод target-объекта ``` -> **Примечание:** Работает из коробки со Spring Boot. Если имена параметров не распознаются, добавьте в maven-compiler-plugin опцию `true`. +> **Примечание:** Для доступа к параметрам по имени (`#userId`) имена параметров должны быть доступны в рантайме (`true`). Можно использовать `#p0/#a0` как fallback. ## Конвертация значений @@ -210,13 +213,13 @@ public class CustomBaggifyConfig { Примеры ситуаций, которые логируются как предупреждения: - `key` пустой или `null` -- `path` не начинается с `#` +- невалидное SpEL-выражение в `path` - Параметр с указанным именем не найден - Дублирующиеся ключи в одной аннотации - Ошибка в кастомном конвертере ``` -WARN WithBaggageAspect : Method 'process': @BaggageField path 'userId' for key 'user.id' must start with '#', skipping +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 ``` diff --git a/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java b/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java index 7d1d02f..0d88800 100644 --- a/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java +++ b/src/main/java/dev/vality/otel/baggify/annotation/BaggageField.java @@ -41,20 +41,25 @@ String key(); /** - * The path to extract the value from method arguments. + * SpEL expression to extract a value. *

- * Must start with {@code #} followed by the parameter name. - * Supports dot-notation for nested field access. + * Supports: + *

    + *
  • Method arguments via variables ({@code #userId}, {@code #request.user.id}, {@code #p0})
  • + *
  • Root object access (target bean), e.g. {@code tenantId} or {@code getTenantId()}
  • + *
  • Regular SpEL operators and null-safe navigation ({@code ?.})
  • + *
* *

Examples: *

    *
  • {@code #userId} - extracts the userId parameter directly
  • *
  • {@code #request.user.id} - navigates to request.getUser().getId()
  • - *
  • {@code #order.customer.email} - nested field access
  • + *
  • {@code #order.customer?.email} - null-safe nested field access
  • + *
  • {@code getDefaultTenantId()} - invoke method on target bean
  • *
* - *

Note: Parameter names must be available at runtime. - * Spring Boot Starter Parent enables this automatically. + *

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 */ diff --git a/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java index 88232e3..dd83612 100644 --- a/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java +++ b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java @@ -44,7 +44,7 @@ *

    *
  • Method parameter names must be available at runtime (compile with {@code -parameters})
  • *
  • Each {@code key} within a single annotation must be unique
  • - *
  • Each {@code path} must start with {@code #parameterName}
  • + *
  • Each {@code path} must be a valid SpEL expression
  • *
* * @see BaggageField diff --git a/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java index 2925c0b..b6a8919 100644 --- a/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java +++ b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java @@ -84,6 +84,7 @@ public Object aroundWithBaggage(ProceedingJoinPoint joinPoint, 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)) { @@ -96,7 +97,7 @@ public Object aroundWithBaggage(ProceedingJoinPoint joinPoint, boolean hasEnrichment = false; for (BaggageField field : withBaggage.value()) { - if (processField(field, method, args, baggageBuilder, currentSpan)) { + if (processField(field, method, args, target, baggageBuilder, currentSpan)) { hasEnrichment = true; } } @@ -171,12 +172,6 @@ private boolean validateConfiguration(WithBaggage withBaggage, Method method) { continue; } - if (!path.startsWith("#")) { - log.warn("Method '{}': @BaggageField path '{}' for key '{}' must start with '#', skipping", - methodName, path, key); - continue; - } - hasValidFields = true; } @@ -196,14 +191,14 @@ private boolean validateConfiguration(WithBaggage withBaggage, Method method) { * @param currentSpan the current span * @return true if value was successfully added to baggage */ - private boolean processField(BaggageField field, Method method, Object[] args, + 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); + Object value = pathValueExtractor.extractValue(path, method, args, target); // Skip null values silently if (value == null) { diff --git a/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java b/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java index c478076..ac9dddd 100644 --- a/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java +++ b/src/main/java/dev/vality/otel/baggify/extractor/PathValueExtractor.java @@ -1,20 +1,25 @@ package dev.vality.otel.baggify.extractor; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.HashMap; -import java.util.Map; - 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 arguments using path expressions. + * Extracts values from method invocation context using SpEL expressions. * *

Supports: *

    - *
  • Direct parameter access: {@code #paramName}
  • - *
  • Nested field access via dot-notation: {@code #paramName.field.subfield}
  • - *
  • JavaBean getters and record accessors
  • + *
  • 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 @@ -23,66 +28,26 @@ */ @Slf4j public class PathValueExtractor { - - private static final String PATH_PREFIX = "#"; - private static final String PATH_SEPARATOR = "\\."; + private final ExpressionParser expressionParser = new SpelExpressionParser(); + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + private final ConcurrentMap expressionCache = new ConcurrentHashMap<>(); /** - * Extracts a value from method arguments using the given path expression. - *

- * This method never throws exceptions. Invalid paths or extraction - * failures result in null return values with warning logs. + * Extracts a value from method arguments/root object using a SpEL expression. * - * @param path the path expression (e.g., "#userId" or "#request.user.id") - * @param method the method being invoked - * @param args the method arguments - * @return the extracted value, or null if not found or path is invalid + * @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) { - // Validate path - if (path == null || path.isBlank()) { - log.warn("Path is null or blank"); - return null; - } - - if (!path.startsWith(PATH_PREFIX)) { - log.warn("Path '{}' must start with '#'", path); - return null; - } - - if (path.length() <= 1) { - log.warn("Path '{}' must specify a parameter name after '#'", path); - return null; - } - + public Object extractValue(String path, Method method, Object[] args, Object rootObject) { try { - String normalizedPath = path.substring(PATH_PREFIX.length()); - String[] pathParts = normalizedPath.split(PATH_SEPARATOR); - String parameterName = pathParts[0]; - - // Find parameter index by name - int paramIndex = findParameterIndex(method, parameterName); - if (paramIndex < 0) { - log.warn("Parameter '{}' not found in method '{}'. " + - "Ensure the parameter exists and code is compiled with -parameters flag.", - parameterName, method.getName()); - return null; - } - - if (paramIndex >= args.length) { - log.warn("Parameter index {} out of bounds for method '{}' with {} arguments", - paramIndex, method.getName(), args.length); - return null; - } + Object[] invocationArgs = args != null ? args : new Object[0]; - Object value = args[paramIndex]; - - // Navigate nested path - for (int i = 1; i < pathParts.length && value != null; i++) { - value = getFieldValue(value, pathParts[i]); - } - - return value; + 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 '{}': {}", @@ -93,85 +58,4 @@ public Object extractValue(String path, Method method, Object[] args) { return null; } } - - /** - * Builds a map of parameter names to their values for the given method invocation. - * - * @param method the method being invoked - * @param args the method arguments - * @return map of parameter name to value - */ - public Map buildParameterMap(Method method, Object[] args) { - Map parameterMap = new HashMap<>(); - Parameter[] parameters = method.getParameters(); - - for (int i = 0; i < parameters.length && i < args.length; i++) { - if (parameters[i].isNamePresent()) { - parameterMap.put(parameters[i].getName(), args[i]); - } - } - - return parameterMap; - } - - private int findParameterIndex(Method method, String parameterName) { - Parameter[] parameters = method.getParameters(); - for (int i = 0; i < parameters.length; i++) { - if (parameters[i].isNamePresent() - && parameters[i].getName().equals(parameterName)) { - return i; - } - } - return -1; - } - - private Object getFieldValue(Object target, String fieldName) { - if (target == null) { - return null; - } - - Class clazz = target.getClass(); - - // Try getter method (getXxx or isXxx for boolean) - String capitalizedName = capitalize(fieldName); - try { - Method getter = clazz.getMethod("get" + capitalizedName); - return getter.invoke(target); - } catch (NoSuchMethodException e) { - // Try boolean getter - try { - Method booleanGetter = clazz.getMethod("is" + capitalizedName); - return booleanGetter.invoke(target); - } catch (NoSuchMethodException ex) { - // Try record accessor (field name as method name) - try { - Method accessor = clazz.getMethod(fieldName); - return accessor.invoke(target); - } catch (NoSuchMethodException exc) { - log.trace("No accessor found for field '{}' on class '{}'", - fieldName, clazz.getSimpleName()); - return null; - } catch (Exception exc) { - log.trace("Failed to invoke accessor '{}' on class '{}': {}", - fieldName, clazz.getSimpleName(), exc.getMessage()); - return null; - } - } catch (Exception ex) { - log.trace("Failed to invoke boolean getter for '{}' on class '{}': {}", - fieldName, clazz.getSimpleName(), ex.getMessage()); - return null; - } - } catch (Exception e) { - log.trace("Failed to invoke getter for '{}' on class '{}': {}", - fieldName, clazz.getSimpleName(), e.getMessage()); - return null; - } - } - - private String capitalize(String str) { - if (str == null || str.isEmpty()) { - return str; - } - return Character.toUpperCase(str.charAt(0)) + str.substring(1); - } } diff --git a/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java b/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java index cf8d943..085ee9f 100644 --- a/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java +++ b/src/test/java/dev/vality/otel/baggify/aspect/WithBaggageAspectTest.java @@ -97,6 +97,18 @@ void shouldEnrichBaggageWithNestedField() { 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 @@ -374,6 +386,11 @@ public BaggageValueConverter testConverterBean() { @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) { @@ -441,6 +458,11 @@ public void invalidPath(String value, Runnable callback) { 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 diff --git a/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java index c43d161..daceee7 100644 --- a/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java +++ b/src/test/java/dev/vality/otel/baggify/extractor/PathValueExtractorTest.java @@ -27,7 +27,7 @@ class PathValidation { void shouldReturnNullForNullPath() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue(null, method, new Object[]{"value"}); + Object result = extractor.extractValue(null, method, new Object[]{"value"}, null); assertThat(result).isNull(); } @@ -37,7 +37,7 @@ void shouldReturnNullForNullPath() throws Exception { void shouldReturnNullForBlankPath() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue(" ", method, new Object[]{"value"}); + Object result = extractor.extractValue(" ", method, new Object[]{"value"}, null); assertThat(result).isNull(); } @@ -47,7 +47,7 @@ void shouldReturnNullForBlankPath() throws Exception { void shouldReturnNullForPathWithoutHashPrefix() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue("userId", method, new Object[]{"value"}); + Object result = extractor.extractValue("userId", method, new Object[]{"value"}, null); assertThat(result).isNull(); } @@ -57,7 +57,7 @@ void shouldReturnNullForPathWithoutHashPrefix() throws Exception { void shouldReturnNullForPathWithOnlyHash() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue("#", method, new Object[]{"value"}); + Object result = extractor.extractValue("#", method, new Object[]{"value"}, null); assertThat(result).isNull(); } @@ -72,7 +72,7 @@ class SimpleParameterExtraction { void shouldExtractStringParameter() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue("#userId", method, new Object[]{"test-user"}); + Object result = extractor.extractValue("#userId", method, new Object[]{"test-user"}, null); assertThat(result).isEqualTo("test-user"); } @@ -82,7 +82,7 @@ void shouldExtractStringParameter() throws Exception { void shouldExtractIntegerParameter() throws Exception { Method method = TestService.class.getMethod("methodWithInteger", Integer.class); - Object result = extractor.extractValue("#count", method, new Object[]{42}); + Object result = extractor.extractValue("#count", method, new Object[]{42}, null); assertThat(result).isEqualTo(42); } @@ -92,7 +92,7 @@ void shouldExtractIntegerParameter() throws Exception { void shouldReturnNullForNullParameter() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue("#userId", method, new Object[]{null}); + Object result = extractor.extractValue("#userId", method, new Object[]{null}, null); assertThat(result).isNull(); } @@ -102,7 +102,7 @@ void shouldReturnNullForNullParameter() throws Exception { void shouldReturnNullForUnknownParameter() throws Exception { Method method = TestService.class.getMethod("simpleMethod", String.class); - Object result = extractor.extractValue("#unknown", method, new Object[]{"value"}); + Object result = extractor.extractValue("#unknown", method, new Object[]{"value"}, null); assertThat(result).isNull(); } @@ -118,7 +118,7 @@ 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}); + Object result = extractor.extractValue("#request.userId", method, new Object[]{request}, null); assertThat(result).isEqualTo("user-123"); } @@ -129,7 +129,7 @@ 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}); + Object result = extractor.extractValue("#request.user.email", method, new Object[]{request}, null); assertThat(result).isEqualTo("john@test.com"); } @@ -140,7 +140,7 @@ 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}); + Object result = extractor.extractValue("#request.user.email", method, new Object[]{request}, null); assertThat(result).isNull(); } @@ -151,7 +151,7 @@ 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}); + Object result = extractor.extractValue("#record.id", method, new Object[]{record}, null); assertThat(result).isEqualTo("record-id"); } @@ -162,7 +162,7 @@ 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}); + Object result = extractor.extractValue("#request.nonExistent", method, new Object[]{request}, null); assertThat(result).isNull(); } @@ -177,12 +177,28 @@ class MultipleParameters { 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"}); + 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") @@ -202,6 +218,9 @@ public void methodWithRecord(TestRecord record) { public void multipleParams(String id, Integer count, String name) { } + + public void noArgsMethod() { + } } public static class TestRequest { @@ -241,4 +260,16 @@ public String getEmail() { } 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; + } + } }