Example usage: + *
{@code
+ * @WithBaggage({
+ * @BaggageField(key = "user.id", path = "#userId"),
+ * @BaggageField(key = "order.id", path = "#request.orderId", addToSpanAttributes = false)
+ * })
+ * public void processOrder(String userId, OrderRequest request) {
+ * // baggage is enriched during method execution
+ * // user.id is also added to span attributes (default)
+ * // order.id is only in baggage
+ * }
+ * }
+ *
+ * @see WithBaggage
+ * @see BaggageValueConverter
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({}) // Only used as part of @WithBaggage
+public @interface BaggageField {
+
+ /**
+ * The baggage key name.
+ * + * Must be non-empty and unique within a single {@link WithBaggage} annotation. + * + * @return the baggage key + */ + String key(); + + /** + * SpEL expression to extract a value. + *
+ * Supports: + *
Examples: + *
Note: Named argument access ({@code #paramName}) requires parameter names + * to be available at runtime. Spring Boot Starter Parent enables this automatically. + * + * @return the extraction path + */ + String path(); + + /** + * Whether to also write this field's value to the current span's attributes. + *
+ * When {@code true} (default): + *
+ * When {@code false}: + *
+ * 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 extends BaggageValueConverter>> converter() default BaggageValueConverter.NoOp.class; + + /** + * Optional Spring bean name of a custom converter. + *
+ * When specified (non-empty), the bean is looked up from the Spring context + * and used for value transformation. Takes highest priority when both + * {@link #converter()} and {@link #converterBean()} are specified. + * + * @return the converter bean name, or empty string if not specified + */ + String converterBean() default ""; +} diff --git a/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java new file mode 100644 index 0000000..dd83612 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/annotation/WithBaggage.java @@ -0,0 +1,71 @@ +package dev.vality.otel.baggify.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declarative annotation for enriching OpenTelemetry Baggage during method execution. + * + *
This annotation provides a Spring AOP mechanism to extract values from method arguments + * and write them to OpenTelemetry Baggage. The baggage enrichment is scoped to the method + * execution time - after the method completes (success or exception), the original OTEL + * context is restored. + * + *
{@code
+ * @WithBaggage(@BaggageField(key = "user.id", path = "#userId"))
+ * public void processUser(String userId) {
+ * // Baggage AND Span Attributes contain "user.id" (addToSpanAttributes=true by default)
+ * }
+ * }
+ *
+ * {@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
+ * }
+ * }
+ *
+ * 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). + * + *
+ * Each field defines a key-path pair for baggage enrichment. + * Keys must be unique within this annotation. + *
+ * By default, each field is also added to span attributes + * (configurable per field via {@link BaggageField#addToSpanAttributes()}). + * + * @return array of baggage field definitions + */ + BaggageField[] value(); +} diff --git a/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java new file mode 100644 index 0000000..b6a8919 --- /dev/null +++ b/src/main/java/dev/vality/otel/baggify/aspect/WithBaggageAspect.java @@ -0,0 +1,241 @@ +package dev.vality.otel.baggify.aspect; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import dev.vality.otel.baggify.annotation.BaggageField; +import dev.vality.otel.baggify.annotation.WithBaggage; +import dev.vality.otel.baggify.converter.BaggageValueConverterResolver; +import dev.vality.otel.baggify.extractor.PathValueExtractor; +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.baggage.BaggageBuilder; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; + +/** + * Spring AOP aspect that handles {@link WithBaggage} annotation processing. + * + *
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. + * + *
This aspect has {@code Ordered.LOWEST_PRECEDENCE - 100} priority to ensure + * it executes after the OpenTelemetry {@code @WithSpan} aspect. + * + * @see WithBaggage + * @see BaggageField + */ +@Slf4j +@Aspect +@RequiredArgsConstructor +public class WithBaggageAspect implements Ordered { + + /** + * Order value for this aspect. + */ + public static final int ASPECT_ORDER = Ordered.LOWEST_PRECEDENCE - 100; + + private final PathValueExtractor pathValueExtractor; + private final BaggageValueConverterResolver converterResolver; + + @Override + public int getOrder() { + return ASPECT_ORDER; + } + + /** + * Around advice that processes {@link WithBaggage} annotated methods. + *
+ * This method never throws exceptions related to baggage processing.
+ * All errors are logged and the original method is always executed.
+ *
+ * @param joinPoint the join point representing the intercepted method
+ * @param withBaggage the WithBaggage annotation
+ * @return the result of the method invocation
+ * @throws Throwable if the method throws an exception
+ */
+ @Around("@annotation(withBaggage)")
+ public Object aroundWithBaggage(ProceedingJoinPoint joinPoint,
+ WithBaggage withBaggage) throws Throwable {
+ Context enrichedContext = null;
+
+ try {
+ MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+ Method method = signature.getMethod();
+ Object[] args = joinPoint.getArgs();
+ Object target = joinPoint.getTarget();
+
+ // Validate configuration and log warnings (don't fail)
+ if (!validateConfiguration(withBaggage, method)) {
+ return joinPoint.proceed();
+ }
+
+ // Build enriched baggage
+ BaggageBuilder baggageBuilder = Baggage.current().toBuilder();
+ Span currentSpan = Span.current();
+ boolean hasEnrichment = false;
+
+ for (BaggageField field : withBaggage.value()) {
+ if (processField(field, method, args, target, baggageBuilder, currentSpan)) {
+ hasEnrichment = true;
+ }
+ }
+
+ // Only create new context if we actually enriched something
+ if (hasEnrichment) {
+ Context currentContext = Context.current();
+ Baggage enrichedBaggage = baggageBuilder.build();
+ enrichedContext = currentContext.with(enrichedBaggage);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to enrich baggage for method '{}': {}",
+ joinPoint.getSignature().getName(), e.getMessage());
+ if (log.isDebugEnabled()) {
+ log.debug("Baggage enrichment error details", e);
+ }
+ }
+
+ // Execute method with enriched context (or original if enrichment failed)
+ if (enrichedContext != null) {
+ try (Scope ignored = enrichedContext.makeCurrent()) {
+ return joinPoint.proceed();
+ }
+ } else {
+ return joinPoint.proceed();
+ }
+ }
+
+ /**
+ * Validates the @WithBaggage configuration.
+ * Logs warnings for any issues but doesn't throw exceptions.
+ *
+ * @param withBaggage the annotation to validate
+ * @param method the annotated method
+ * @return true if configuration is valid and processing should continue
+ */
+ private boolean validateConfiguration(WithBaggage withBaggage, Method method) {
+ BaggageField[] fields = withBaggage.value();
+ String methodName = method.getName();
+
+ if (fields == null || fields.length == 0) {
+ log.warn("Method '{}': @WithBaggage has no @BaggageField definitions, skipping",
+ methodName);
+ return false;
+ }
+
+ Set
+ * Never throws exceptions - all errors are logged as warnings.
+ *
+ * @param field the baggage field definition
+ * @param method the method being invoked
+ * @param args the method arguments
+ * @param baggageBuilder the baggage builder to add value to
+ * @param currentSpan the current span
+ * @return true if value was successfully added to baggage
+ */
+ private boolean processField(BaggageField field, Method method, Object[] args, Object target,
+ BaggageBuilder baggageBuilder, Span currentSpan) {
+ String key = field.key();
+ String path = field.path();
+
+ try {
+ // Extract value using path
+ Object value = pathValueExtractor.extractValue(path, method, args, target);
+
+ // Skip null values silently
+ if (value == null) {
+ log.trace("Method '{}': Value at path '{}' is null, skipping key '{}'",
+ method.getName(), path, key);
+ return false;
+ }
+
+ // Convert to string
+ String stringValue = converterResolver.convert(value, field);
+
+ // Skip null converted values
+ if (stringValue == null) {
+ log.trace("Method '{}': Converted value for key '{}' is null, skipping",
+ method.getName(), key);
+ return false;
+ }
+
+ // Add to baggage
+ baggageBuilder.put(key, stringValue);
+
+ // Add to span attributes if enabled for this field (default: true)
+ if (field.addToSpanAttributes() && currentSpan.isRecording()) {
+ currentSpan.setAttribute(key, stringValue);
+ }
+
+ log.trace("Method '{}': Added baggage key '{}' with value '{}'",
+ method.getName(), key, stringValue);
+ return true;
+
+ } catch (Exception e) {
+ log.warn("Method '{}': Failed to process @BaggageField with key '{}' and path '{}': {}",
+ method.getName(), key, path, e.getMessage());
+ if (log.isDebugEnabled()) {
+ log.debug("Baggage field processing error details", e);
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java b/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java
new file mode 100644
index 0000000..23989cb
--- /dev/null
+++ b/src/main/java/dev/vality/otel/baggify/config/BaggifyAutoConfiguration.java
@@ -0,0 +1,80 @@
+package dev.vality.otel.baggify.config;
+
+import dev.vality.otel.baggify.aspect.WithBaggageAspect;
+import dev.vality.otel.baggify.converter.BaggageValueConverterResolver;
+import dev.vality.otel.baggify.extractor.PathValueExtractor;
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+import org.springframework.core.convert.ConversionService;
+import org.springframework.lang.Nullable;
+
+/**
+ * Spring Boot Auto Configuration for otel-baggify library.
+ *
+ * This configuration automatically sets up the necessary beans for
+ * {@link dev.vality.otel.baggify.annotation.WithBaggage} annotation processing:
+ * This configuration is only activated when OpenTelemetry API classes are present
+ * on the classpath.
+ *
+ * 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.
+ *
+ *
+ * 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
+ *
+ *
+ * Conditional Activation
+ * Customization
+ * Usage
+ * {@code
+ * public class UserIdConverter implements BaggageValueConverter
+ *
+ * Spring Bean Converter
+ * {@code
+ * @Component("customConverter")
+ * public class CustomConverter implements BaggageValueConverter
+ *
+ * @param