diff --git a/docs/manual-instrumentation.md b/docs/manual-instrumentation.md index 591c536aa2ca..de18e348090f 100644 --- a/docs/manual-instrumentation.md +++ b/docs/manual-instrumentation.md @@ -88,6 +88,29 @@ Each time the application invokes the annotated method, it creates a span that d and provides any thrown exceptions. Unless specified as an argument to the annotation, the span name will be `.`. + +## Adding attributes to the span with `@SpanAttribute` + +When a span is created for an annotated method the values of the arguments to the method invocation +can be automatically added as attributes to the created span by annotating the method parameters +with the `@SpanAttribute` annotation. + +```java +import io.opentelemetry.extension.annotations.SpanAttribute; +import io.opentelemetry.extension.annotations.WithSpan; + +public class MyClass { + @WithSpan + public void MyLogic(@SpanAttribute("parameter1") String parameter1, @SpanAttribute("parameter2") long parameter2) { + <...> + } +} +``` + +Unless specified as an argument to the annotation, the attribute name will be derived from the +formal parameter names if they are compiled into the `.class` files by passing the `-parameters` +option to the `javac` compiler. + ## Suppressing `@WithSpan` instrumentation Suppressing `@WithSpan` is useful if you have code that is over-instrumented using `@WithSpan` diff --git a/instrumentation-api-annotation-support/build.gradle.kts b/instrumentation-api-annotation-support/build.gradle.kts new file mode 100644 index 000000000000..60c0740d4679 --- /dev/null +++ b/instrumentation-api-annotation-support/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("otel.java-conventions") + id("otel.jacoco-conventions") + id("otel.publish-conventions") +} + +group = "io.opentelemetry.instrumentation" + +dependencies { + implementation(project(":instrumentation-api")) + + api("io.opentelemetry:opentelemetry-api") + api("io.opentelemetry:opentelemetry-semconv") + + implementation("io.opentelemetry:opentelemetry-api-metrics") + implementation("org.slf4j:slf4j-api") + + compileOnly("com.google.auto.value:auto-value-annotations") + annotationProcessor("com.google.auto.value:auto-value") + + testImplementation(project(":testing-common")) + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") + testImplementation("org.assertj:assertj-core") + testImplementation("org.awaitility:awaitility") + testImplementation("io.opentelemetry:opentelemetry-sdk-metrics") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelper.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelper.java new file mode 100644 index 000000000000..cf7467a80255 --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelper.java @@ -0,0 +1,93 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import java.lang.annotation.Annotation; +import java.lang.invoke.CallSite; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.function.Function; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Helper class for reflecting over annotations at runtime.. */ +public class AnnotationReflectionHelper { + private AnnotationReflectionHelper() {} + + /** + * Returns the {@link Class Class<? extends Annotation>} for the name of the {@link + * Annotation} at runtime, otherwise returns {@code null}. + */ + @Nullable + public static Class forNameOrNull( + ClassLoader classLoader, String className) { + try { + return Class.forName(className, true, classLoader).asSubclass(Annotation.class); + } catch (ClassNotFoundException | ClassCastException exception) { + return null; + } + } + + /** + * Binds a lambda of the functional interface {@link Function Function<A extends Annotation, + * T>} to the element of an {@link Annotation} class by name which, when invoked with an + * instance of that annotation, will return the value of that element. + * + *

For example, calling this method as follows: + * + *

{@code
+   * Function function = AnnotationReflectionHelper.bindAnnotationElementMethod(
+   *     MethodHandles.lookup(),
+   *     WithSpan.class,
+   *     "value",
+   *     String.class);
+   * }
+ * + *

is equivalent to the following Java code: + * + *

{@code
+   * Function function = WithSpan::value;
+   * }
+ * + * @param lookup the {@link MethodHandles.Lookup} of the calling method, e.g. {@link + * MethodHandles#lookup()} + * @param annotationClass the {@link Class} of the {@link Annotation} + * @param methodName name of the annotation element method + * @param returnClass type of the annotation element + * @param the type of the annotation + * @param the type of the annotation element + * @return Instance of {@link Function Function<Annotation, T>} that is bound to the + * annotation element method + * @throws NoSuchMethodException the annotation element method was not found + * @throws Throwable on failing to bind to the + */ + public static Function bindAnnotationElementMethod( + MethodHandles.Lookup lookup, + Class annotationClass, + String methodName, + Class returnClass) + throws Throwable { + + MethodHandle valueHandle = + lookup.findVirtual(annotationClass, methodName, MethodType.methodType(returnClass)); + + CallSite callSite = + LambdaMetafactory.metafactory( + lookup, + "apply", + MethodType.methodType(Function.class), + MethodType.methodType(Object.class, Object.class), + valueHandle, + MethodType.methodType(returnClass, annotationClass)); + + MethodHandle factory = callSite.getTarget(); + + @SuppressWarnings("unchecked") + Function function = (Function) factory.invoke(); + return function; + } +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBinding.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBinding.java new file mode 100644 index 000000000000..88f2610162b7 --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBinding.java @@ -0,0 +1,21 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import io.opentelemetry.instrumentation.api.tracer.AttributeSetter; + +/** Represents the binding of a method parameter to an attribute of a traced method. */ +@FunctionalInterface +public interface AttributeBinding { + + /** + * Applies the value of the method argument as an attribute on the span for the traced method. + * + * @param setter the {@link AttributeSetter} onto which to add the attribute + * @param arg the value of the method argument + */ + void apply(AttributeSetter setter, Object arg); +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactory.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactory.java new file mode 100644 index 000000000000..4552fafb6fb7 --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactory.java @@ -0,0 +1,341 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import io.opentelemetry.api.common.AttributeKey; +import java.lang.reflect.Type; +import java.util.AbstractList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Helper class for creating {@link AttributeBinding} instances based on the {@link Type} of the + * parameter for a traced method. + */ +class AttributeBindingFactory { + private AttributeBindingFactory() {} + + static AttributeBinding createBinding(String name, Type type) { + + // Simple scalar parameter types + if (type == String.class) { + AttributeKey key = AttributeKey.stringKey(name); + return (setter, arg) -> setter.setAttribute(key, (String) arg); + } + if (type == long.class || type == Long.class) { + AttributeKey key = AttributeKey.longKey(name); + return (setter, arg) -> setter.setAttribute(key, (Long) arg); + } + if (type == double.class || type == Double.class) { + AttributeKey key = AttributeKey.doubleKey(name); + return (setter, arg) -> setter.setAttribute(key, (Double) arg); + } + if (type == boolean.class || type == Boolean.class) { + AttributeKey key = AttributeKey.booleanKey(name); + return (setter, arg) -> setter.setAttribute(key, (Boolean) arg); + } + if (type == int.class || type == Integer.class) { + AttributeKey key = AttributeKey.longKey(name); + return (setter, arg) -> setter.setAttribute(key, ((Integer) arg).longValue()); + } + if (type == float.class || type == Float.class) { + AttributeKey key = AttributeKey.doubleKey(name); + return (setter, arg) -> setter.setAttribute(key, ((Float) arg).doubleValue()); + } + + if (isArrayType(type)) { + return arrayBinding(name, type); + } + + return resolveListComponentType(type) + .map(componentType -> listBinding(name, componentType)) + .orElseGet(() -> defaultBinding(name)); + } + + private static boolean isArrayType(Type type) { + if (type instanceof Class) { + return ((Class) type).isArray(); + } + return false; + } + + private static Optional resolveListComponentType(Type type) { + return ParameterizedClass.of(type) + .findParameterizedSuperclass(List.class) + .map(pc -> pc.getActualTypeArguments()[0]); + } + + private static AttributeBinding arrayBinding(String name, Type type) { + // Simple array attribute types without conversion + if (type == String[].class) { + AttributeKey> key = AttributeKey.stringArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, Arrays.asList((String[]) arg)); + } + if (type == Long[].class) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, Arrays.asList((Long[]) arg)); + } + if (type == Double[].class) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, Arrays.asList((Double[]) arg)); + } + if (type == Boolean[].class) { + AttributeKey> key = AttributeKey.booleanArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, Arrays.asList((Boolean[]) arg)); + } + + if (type == long[].class) { + return longArrayBinding(name); + } + if (type == int[].class) { + return intArrayBinding(name); + } + if (type == float[].class) { + return floatArrayBinding(name); + } + if (type == double[].class) { + return doubleArrayBinding(name); + } + if (type == boolean[].class) { + return booleanArrayBinding(name); + } + if (type == Integer[].class) { + return boxedIntegerArrayBinding(name); + } + if (type == Float[].class) { + return boxedFloatArrayBinding(name); + } + + return defaultArrayBinding(name); + } + + @SuppressWarnings("unchecked") + private static AttributeBinding listBinding(String name, Type componentType) { + if (componentType == String.class) { + AttributeKey> key = AttributeKey.stringArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, (List) arg); + } + if (componentType == Long.class) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, (List) arg); + } + if (componentType == Double.class) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, (List) arg); + } + if (componentType == Boolean.class) { + AttributeKey> key = AttributeKey.booleanArrayKey(name); + return (setter, arg) -> setter.setAttribute(key, (List) arg); + } + if (componentType == Integer.class) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return mappedListBinding(Integer.class, key, Integer::longValue); + } + if (componentType == Float.class) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return mappedListBinding(Float.class, key, Float::doubleValue); + } + + return defaultListBinding(name); + } + + private static AttributeBinding intArrayBinding(String name) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return (setter, arg) -> { + int[] array = (int[]) arg; + List wrapper = + new AbstractList() { + @Override + public Long get(int index) { + return (long) array[index]; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding boxedIntegerArrayBinding(String name) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return (setter, arg) -> { + Integer[] array = (Integer[]) arg; + List wrapper = + new AbstractList() { + @Override + public Long get(int index) { + Integer value = array[index]; + return value != null ? value.longValue() : null; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding longArrayBinding(String name) { + AttributeKey> key = AttributeKey.longArrayKey(name); + return (setter, arg) -> { + long[] array = (long[]) arg; + List wrapper = + new AbstractList() { + @Override + public Long get(int index) { + return array[index]; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding floatArrayBinding(String name) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return (setter, arg) -> { + float[] array = (float[]) arg; + List wrapper = + new AbstractList() { + @Override + public Double get(int index) { + return (double) array[index]; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding boxedFloatArrayBinding(String name) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return (setter, arg) -> { + Float[] array = (Float[]) arg; + List wrapper = + new AbstractList() { + @Override + public Double get(int index) { + Float value = array[index]; + return value != null ? value.doubleValue() : null; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding doubleArrayBinding(String name) { + AttributeKey> key = AttributeKey.doubleArrayKey(name); + return (setter, arg) -> { + double[] array = (double[]) arg; + List wrapper = + new AbstractList() { + @Override + public Double get(int index) { + return array[index]; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding booleanArrayBinding(String name) { + AttributeKey> key = AttributeKey.booleanArrayKey(name); + return (setter, arg) -> { + boolean[] array = (boolean[]) arg; + List wrapper = + new AbstractList() { + @Override + public Boolean get(int index) { + return array[index]; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding defaultArrayBinding(String name) { + AttributeKey> key = AttributeKey.stringArrayKey(name); + return (setter, arg) -> { + Object[] array = (Object[]) arg; + List wrapper = + new AbstractList() { + @Override + public String get(int index) { + Object value = array[index]; + return value != null ? value.toString() : null; + } + + @Override + public int size() { + return array.length; + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding mappedListBinding( + Class fromClass, AttributeKey> key, Function mapping) { + return (setter, arg) -> { + @SuppressWarnings("unchecked") + List list = (List) arg; + List wrapper = + new AbstractList() { + @Override + public U get(int index) { + T value = list.get(index); + return value != null ? mapping.apply(value) : null; + } + + @Override + public int size() { + return list.size(); + } + }; + setter.setAttribute(key, wrapper); + }; + } + + private static AttributeBinding defaultListBinding(String name) { + AttributeKey> key = AttributeKey.stringArrayKey(name); + return mappedListBinding(Object.class, key, Object::toString); + } + + private static AttributeBinding defaultBinding(String name) { + AttributeKey key = AttributeKey.stringKey(name); + return (setter, arg) -> setter.setAttribute(key, arg.toString()); + } +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindings.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindings.java new file mode 100644 index 000000000000..6717a5b14fbf --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindings.java @@ -0,0 +1,27 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import io.opentelemetry.instrumentation.api.tracer.AttributeSetter; + +/** Represents the bindings of method parameters to attributes of a traced method. */ +public interface AttributeBindings { + + /** + * Indicates that the traced method has no parameters bound to attributes. + * + * @return {@code true} if the traced method has no bound parameters; otherwise {@code false} + */ + boolean isEmpty(); + + /** + * Applies the values of the method arguments as attributes on the span for the traced method. + * + * @param setter the {@link AttributeSetter} for setting the attribute on the span + * @param args the method arguments + */ + void apply(AttributeSetter setter, Object[] args); +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinder.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinder.java new file mode 100644 index 000000000000..c76b556356ed --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinder.java @@ -0,0 +1,106 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import io.opentelemetry.instrumentation.api.tracer.AttributeSetter; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Base class for instrumentation-specific attribute binding for traced methods. */ +public abstract class BaseAttributeBinder { + + /** + * Creates a binding of the parameters of the traced method to span attributes. + * + * @param method the traced method + * @return the bindings of the parameters + */ + public AttributeBindings bind(Method method) { + AttributeBindings bindings = EmptyAttributeBindings.INSTANCE; + + Parameter[] parameters = method.getParameters(); + if (parameters == null || parameters.length == 0) { + return bindings; + } + + String[] attributeNames = attributeNamesForParameters(method, parameters); + if (attributeNames == null || attributeNames.length != parameters.length) { + return bindings; + } + + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + String attributeName = attributeNames[i]; + if (attributeName == null || attributeName.isEmpty()) { + continue; + } + + bindings = + new CombinedAttributeBindings( + bindings, + i, + AttributeBindingFactory.createBinding( + attributeName, parameter.getParameterizedType())); + } + + return bindings; + } + + /** + * Returns an array of the names of the attributes for the parameters of the traced method. The + * array should be the same length as the array of the method parameters. An element may be {@code + * null} to indicate that the parameter should not be bound to an attribute. The array may also be + * {@code null} to indicate that the method has no parameters to bind to attributes. + * + * @param method the traced method + * @param parameters the method parameters + * @return an array of the attribute names + */ + @Nullable + protected abstract String[] attributeNamesForParameters(Method method, Parameter[] parameters); + + protected enum EmptyAttributeBindings implements AttributeBindings { + INSTANCE; + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public void apply(AttributeSetter setter, Object[] args) {} + } + + private static final class CombinedAttributeBindings implements AttributeBindings { + private final AttributeBindings parent; + private final int index; + private final AttributeBinding binding; + + public CombinedAttributeBindings( + AttributeBindings parent, int index, AttributeBinding binding) { + this.parent = parent; + this.index = index; + this.binding = binding; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public void apply(AttributeSetter setter, Object[] args) { + parent.apply(setter, args); + if (args != null && args.length > index) { + Object arg = args[index]; + if (arg != null) { + binding.apply(setter, arg); + } + } + } + } +} diff --git a/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClass.java b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClass.java new file mode 100644 index 000000000000..c76d3deb0072 --- /dev/null +++ b/instrumentation-api-annotation-support/src/main/java/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClass.java @@ -0,0 +1,174 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.Objects; +import java.util.Optional; + +/** + * Helper class for reflecting over the type hierarchy of parameterized types where the generic type + * arguments are reified and/or mapped to the type parameters of the implemented superclasses or + * interfaces. + * + *

The only way to walk the type hierarchy of a {@link ParameterizedType} is through {@link + * ParameterizedType#getRawType} which returns an instance of {@link Class Class<?>} which + * loses the mapping with the actual type arguments. This helper class tracks the association + * between the type variables of the class with the type parameters of the superclass in order to + * map the actual type arguments to those type parameters. This makes it possible to determine the + * actual type arguments to the generic interfaces and superclasses in the type hierarchy. + * + *

For example, given the parameterized type {@link java.util.ArrayList ArrayList<String>} + * you can determine that the superclass is {@link java.util.AbstractList + * AbstractList<String>} which implements generic interfaces such as {@link java.util.List + * List<String>} and {@link java.util.Collection Collection<String>}. + */ +final class ParameterizedClass { + private final Class rawClass; + private final Type[] typeArguments; + + public static ParameterizedClass of(Type type) { + Objects.requireNonNull(type); + if (type instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) type; + Class rawClass = (Class) parameterizedType.getRawType(); + return new ParameterizedClass(rawClass, parameterizedType.getActualTypeArguments()); + } else if (type instanceof Class) { + Class cls = (Class) type; + return new ParameterizedClass(cls, cls.getTypeParameters()); + } + throw new IllegalArgumentException(); + } + + private ParameterizedClass(Class rawClass, Type[] typeArguments) { + this.rawClass = rawClass; + this.typeArguments = typeArguments; + } + + /** Gets the raw class of the parameterized class. */ + public Class getRawClass() { + return rawClass; + } + + /** Gets the actual type arguments of the parameterized class. */ + public Type[] getActualTypeArguments() { + return typeArguments.clone(); + } + + /** Gets the parameterized superclass of the current parameterized class. */ + public ParameterizedClass getParameterizedSuperclass() { + return resolveSuperTypeActualTypeArguments(rawClass.getGenericSuperclass()); + } + + /** Gets an array of the parameterized interfaces of the current parameterized class. */ + public ParameterizedClass[] getParameterizedInterfaces() { + Type[] interfaceTypes = rawClass.getGenericInterfaces(); + ParameterizedClass[] parameterizedClasses = new ParameterizedClass[interfaceTypes.length]; + for (int i = 0; i < interfaceTypes.length; i++) { + parameterizedClasses[i] = resolveSuperTypeActualTypeArguments(interfaceTypes[i]); + } + return parameterizedClasses; + } + + /** + * Walks the type hierarchy of the parameterized class to determine if it extends from or + * implements the specified super class. + */ + public Optional findParameterizedSuperclass(Class superClass) { + Objects.requireNonNull(superClass); + return findParameterizedSuperclassImpl(this, superClass); + } + + private static Optional findParameterizedSuperclassImpl( + ParameterizedClass current, Class superClass) { + + if (current == null) { + return Optional.empty(); + } + if (current.rawClass.equals(superClass)) { + return Optional.of(current); + } + if (superClass.isInterface()) { + for (ParameterizedClass interfaceType : current.getParameterizedInterfaces()) { + if (interfaceType.rawClass.equals(superClass)) { + return Optional.of(interfaceType); + } + } + } + return findParameterizedSuperclassImpl(current.getParameterizedSuperclass(), superClass); + } + + private ParameterizedClass resolveSuperTypeActualTypeArguments(Type superType) { + if (superType instanceof ParameterizedType) { + ParameterizedType parameterizedSuperType = (ParameterizedType) superType; + TypeVariable[] typeParameters = rawClass.getTypeParameters(); + return resolveSuperTypeActualTypeArguments( + parameterizedSuperType, typeParameters, typeArguments); + } else if (superType != null) { + return ParameterizedClass.of(superType); + } + return null; + } + + /** + * Maps the actual type arguments to the type parameters of the superclass based on the identity + * of the type variables. + */ + private static ParameterizedClass resolveSuperTypeActualTypeArguments( + ParameterizedType parameterizedSuperType, + TypeVariable[] typeParameters, + Type[] actualTypeArguments) { + Type[] superTypeArguments = parameterizedSuperType.getActualTypeArguments(); + + for (int i = 0; i < superTypeArguments.length; i++) { + Type superTypeArgument = superTypeArguments[i]; + if (superTypeArgument instanceof TypeVariable) { + TypeVariable superTypeVariable = (TypeVariable) superTypeArgument; + superTypeArguments[i] = + mapTypeVariableToActualTypeArgument( + superTypeVariable, typeParameters, actualTypeArguments); + } + } + Class rawSuperClass = (Class) parameterizedSuperType.getRawType(); + return new ParameterizedClass(rawSuperClass, superTypeArguments); + } + + private static Type mapTypeVariableToActualTypeArgument( + TypeVariable superTypeVariable, + TypeVariable[] typeParameters, + Type[] actualTypeArguments) { + for (int i = 0; i < typeParameters.length; i++) { + if (equalsTypeVariable(superTypeVariable, typeParameters[i])) { + return actualTypeArguments[i]; + } + } + return superTypeVariable; + } + + /** + * Ensures that any two type variables are equal as long as they are declared by the same {@link + * java.lang.reflect.GenericDeclaration} and have the same name, even if their bounds differ. + * + *

While resolving a type variable from a {@code var -> type} map, we don't care whether the + * type variable's bound has been partially resolved. As long as the type variable "identity" + * matches. + * + *

On the other hand, if for example we are resolving {@code List} to {@code + * List}, we need to compare that {@code } is unequal to {@code } in order to decide to use the transformed type instead of the original type. + */ + private static boolean equalsTypeVariable(TypeVariable left, TypeVariable right) { + if (left == right) { + return true; + } else if (left == null || right == null) { + return false; + } + return left.getGenericDeclaration().equals(right.getGenericDeclaration()) + && left.getName().equals(right.getName()); + } +} diff --git a/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelperTest.groovy b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelperTest.groovy new file mode 100644 index 000000000000..f1155a93b3d9 --- /dev/null +++ b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AnnotationReflectionHelperTest.groovy @@ -0,0 +1,60 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support + +import spock.lang.Specification + +import java.lang.invoke.MethodHandles + +class AnnotationReflectionHelperTest extends Specification { + + def "returns the annotation type by name"() { + given: + def cls = AnnotationReflectionHelper.forNameOrNull( + AnnotationReflectionHelperTest.classLoader, + "io.opentelemetry.instrumentation.api.annotation.support.CustomAnnotation") + + expect: + cls == CustomAnnotation + } + + def "returns null for an annotation not found"() { + given: + def cls = AnnotationReflectionHelper.forNameOrNull( + AnnotationReflectionHelperTest.classLoader, + "io.opentelemetry.instrumentation.api.annotation.support.NonExistentAnnotation") + + expect: + cls == null + } + + def "returns null for a class that is not an annotation"() { + given: + def cls = AnnotationReflectionHelper.forNameOrNull( + AnnotationReflectionHelperTest.classLoader, + "java.util.List") + + expect: + cls == null + } + + def "returns bound functional interface to annotation element"() { + given: + def function = AnnotationReflectionHelper.bindAnnotationElementMethod( + MethodHandles.lookup(), + CustomAnnotation, + "value", + String + ) + def annotation = Annotated.getDeclaredAnnotation(CustomAnnotation) + + expect: + function.apply(annotation) == "Value" + } + + @CustomAnnotation(value = "Value") + class Annotated { } +} diff --git a/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactoryTest.groovy b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactoryTest.groovy new file mode 100644 index 000000000000..de89984ddce0 --- /dev/null +++ b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/AttributeBindingFactoryTest.groovy @@ -0,0 +1,303 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support + +import io.opentelemetry.api.common.AttributeType +import io.opentelemetry.instrumentation.api.tracer.AttributeSetter +import spock.lang.Specification + +class AttributeBindingFactoryTest extends Specification { + + AttributeSetter setter = Mock() + + def "creates attribute binding for String"() { + when: + AttributeBindingFactory.createBinding("key", String).apply(setter, "value") + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING && it.getKey() == "key"}, "value") + } + + def "creates attribute binding for int"() { + when: + AttributeBindingFactory.createBinding("key", int).apply(setter, 1234) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG && it.getKey() == "key"}, 1234L) + } + + def "creates attribute binding for Integer"() { + when: + AttributeBindingFactory.createBinding("key", Integer).apply(setter, 1234) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG && it.getKey() == "key"}, 1234L) + } + + def "creates attribute binding for long"() { + when: + AttributeBindingFactory.createBinding("key", long).apply(setter, 1234L) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG && it.getKey() == "key"}, 1234L) + } + + def "creates attribute binding for Long"() { + when: + AttributeBindingFactory.createBinding("key", Long).apply(setter, 1234L) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG && it.getKey() == "key"}, 1234L) + } + + def "creates attribute binding for float"() { + when: + AttributeBindingFactory.createBinding("key", float).apply(setter, 1234.0F) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE && it.getKey() == "key"}, 1234.0) + } + + def "creates attribute binding for Float"() { + when: + AttributeBindingFactory.createBinding("key", Float).apply(setter, 1234.0F) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE && it.getKey() == "key"}, 1234.0) + } + + def "creates attribute binding for double"() { + when: + AttributeBindingFactory.createBinding("key", double).apply(setter, Double.valueOf(1234.0)) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE && it.getKey() == "key"}, Double.valueOf(1234.0)) + } + + def "creates attribute binding for Double"() { + when: + AttributeBindingFactory.createBinding("key", Double).apply(setter, Double.valueOf(1234.0)) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE && it.getKey() == "key"}, Double.valueOf(1234.0)) + } + + def "creates attribute binding for boolean"() { + when: + AttributeBindingFactory.createBinding("key", boolean).apply(setter, true) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.BOOLEAN && it.getKey() == "key"}, true) + } + + def "creates attribute binding for Boolean"() { + when: + AttributeBindingFactory.createBinding("key", Boolean).apply(setter, true) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.BOOLEAN && it.getKey() == "key"}, true) + } + + def "creates attribute binding for String[]"() { + when: + AttributeBindingFactory.createBinding("key", String[]).apply(setter, ["x", "y", "z", null ] as String[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING_ARRAY && it.getKey() == "key"}, ["x", "y", "z", null ]) + } + + def "creates attribute binding for int[]"() { + when: + AttributeBindingFactory.createBinding("key", int[]).apply(setter, [1, 2, 3 ] as int[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L ]) + } + + def "creates attribute binding for Integer[]"() { + when: + AttributeBindingFactory.createBinding("key", Integer[]).apply(setter, [1, 2, 3, null ] as Integer[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L, null ]) + } + + def "creates attribute binding for long[]"() { + when: + AttributeBindingFactory.createBinding("key", long[]).apply(setter, [1L, 2L, 3L ] as long[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L ]) + } + + def "creates attribute binding for Long[]"() { + when: + AttributeBindingFactory.createBinding("key", Long[]).apply(setter, [1L, 2L, 3L, null ] as Long[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L, null ]) + } + + def "creates attribute binding for float[]"() { + when: + AttributeBindingFactory.createBinding("key", float[]).apply(setter, [1.0F, 2.0F, 3.0F ] as float[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0 ] as List) + } + + def "creates attribute binding for Float[]"() { + when: + AttributeBindingFactory.createBinding("key", Float[]).apply(setter, [1.0F, 2.0F, 3.0F, null ] as Float[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0, null ] as List) + } + + def "creates attribute binding for double[]"() { + when: + AttributeBindingFactory.createBinding("key", double[]).apply(setter, [1.0, 2.0, 3.0 ] as double[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0 ] as List) + } + + def "creates attribute binding for Double[]"() { + when: + AttributeBindingFactory.createBinding("key", Double[]).apply(setter, [1.0, 2.0, 3.0, null ] as Double[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0, null ] as List) + } + + def "creates attribute binding for boolean[]"() { + when: + AttributeBindingFactory.createBinding("key", boolean[]).apply(setter, [true, false ] as boolean[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.BOOLEAN_ARRAY && it.getKey() == "key"}, [true, false ] as List) + } + + def "creates attribute binding for Boolean[]"() { + when: + AttributeBindingFactory.createBinding("key", Boolean[]).apply(setter, [true, false, null ] as Boolean[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.BOOLEAN_ARRAY && it.getKey() == "key"}, [true, false, null ] as List) + } + + def "creates default attribute binding"() { + when: + AttributeBindingFactory.createBinding("key", TestClass).apply(setter, new TestClass("foo")) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING && it.getKey() == "key"}, "TestClass{value = foo}") + } + + def "creates default attribute binding for array"() { + when: + AttributeBindingFactory.createBinding("key", TestClass[]).apply(setter, [new TestClass("foo"), new TestClass("bar"), null] as TestClass[]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING_ARRAY && it.getKey() == "key"}, ["TestClass{value = foo}", "TestClass{value = bar}", null ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("stringList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, ["x", "y", "z" ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING_ARRAY && it.getKey() == "key"}, ["x", "y", "z" ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("integerList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [1, 2, 3 ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("longList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [1L, 2L, 3L ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("floatList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [1.0F, 2.0F, 3.0F ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0 ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("doubleList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [1.0, 2.0, 3.0 ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.DOUBLE_ARRAY && it.getKey() == "key"}, [1.0, 2.0, 3.0 ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("booleanList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [true, false, null ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.BOOLEAN_ARRAY && it.getKey() == "key"}, [true, false, null ]) + } + + def "creates attribute binding for List"() { + when: + def type = TestFields.getDeclaredField("otherList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [new TestClass("foo"), new TestClass("bar") ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.STRING_ARRAY && it.getKey() == "key"}, ["TestClass{value = foo}", "TestClass{value = bar}" ]) + } + + def "creates attribute binding for ArrayList"() { + when: + def type = TestFields.getDeclaredField("longArrayList").getGenericType() + AttributeBindingFactory.createBinding("key", type).apply(setter, [1L, 2L, 3L ]) + + then: + 1 * setter.setAttribute({ it.getType() == AttributeType.LONG_ARRAY && it.getKey() == "key"}, [1L, 2L, 3L ]) + } + + class TestClass { + final String value + + TestClass(String value) { + this.value = value + } + + @Override + String toString() { + return "TestClass{value = " + value + "}" + } + } + + class TestFields { + List stringList + List longList + List doubleList + List booleanList + List integerList + List floatList + List otherList + ArrayList longArrayList + } +} diff --git a/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinderTest.groovy b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinderTest.groovy new file mode 100644 index 000000000000..6dd1f728557b --- /dev/null +++ b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/BaseAttributeBinderTest.groovy @@ -0,0 +1,122 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support + +import io.opentelemetry.instrumentation.api.tracer.AttributeSetter +import spock.lang.Shared +import spock.lang.Specification + +import java.lang.reflect.Method +import java.lang.reflect.Parameter + +class BaseAttributeBinderTest extends Specification { + @Shared + Method method = TestClass.getDeclaredMethod("method", String, String, String) + + @Shared + Object[] args = [ "a", "b", "c" ] + + AttributeSetter setter = Mock() + + def "returns empty bindings for null attribute names array"() { + given: + def binder = new TestAttributeBinder(null) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + bindings.isEmpty() + 0 * setter.setAttribute(*spock.lang.Specification._) + } + + def "returns empty bindings for empty attribute names array"() { + given: + def binder = new TestAttributeBinder(new String[0]) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + bindings.isEmpty() + 0 * setter.setAttribute(*spock.lang.Specification._) + } + + def "returns empty bindings for attribute names array with all null elements"() { + given: + def binder = new TestAttributeBinder([ null, null, null ] as String[]) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + bindings.isEmpty() + 0 * setter.setAttribute(*spock.lang.Specification._) + } + + def "returns empty bindings for attribute names array with fewer elements than parameters"() { + given: + def binder = new TestAttributeBinder([ "x", "y" ] as String[]) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + bindings.isEmpty() + 0 * setter.setAttribute(*spock.lang.Specification._) + } + + def "returns bindings for attribute names array"() { + given: + def binder = new TestAttributeBinder([ "x", "y", "z" ] as String[]) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + !bindings.isEmpty() + 1 * setter.setAttribute({ it.getKey() == "x" }, "a") + 1 * setter.setAttribute({ it.getKey() == "y" }, "b") + 1 * setter.setAttribute({ it.getKey() == "z" }, "c") + } + + def "returns bindings for attribute names with null name"() { + given: + def binder = new TestAttributeBinder([ "x", null, "z" ] as String[]) + + when: + AttributeBindings bindings = binder.bind(method) + bindings.apply(setter, args) + + then: + !bindings.isEmpty() + 1 * setter.setAttribute({ it.getKey() == "x" }, "a") + 0 * setter.setAttribute(spock.lang.Specification._, "b") + 1 * setter.setAttribute({ it.getKey() == "z" }, "c") + } + + class TestAttributeBinder extends BaseAttributeBinder { + final String[] attributeNames + + TestAttributeBinder(String[] attributeNames) { + this.attributeNames = attributeNames + } + + @Override + protected String[] attributeNamesForParameters(Method method, Parameter[] parameters) { + return attributeNames + } + } + + class TestClass { + void method(String x, String y, String z) { } + } +} diff --git a/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClassTest.groovy b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClassTest.groovy new file mode 100644 index 000000000000..4323ec16fe89 --- /dev/null +++ b/instrumentation-api-annotation-support/src/test/groovy/io/opentelemetry/instrumentation/api/annotation/support/ParameterizedClassTest.groovy @@ -0,0 +1,128 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support + +import spock.lang.Shared +import spock.lang.Specification +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +import static spock.util.matcher.HamcrestSupport.* +import static org.hamcrest.Matchers.* + +import java.lang.reflect.Type +import java.lang.reflect.TypeVariable + +class ParameterizedClassTest extends Specification { + @Shared + def stringListType = TestFields.getField("stringListField").getGenericType() + + @Shared + def stringArrayListType = TestFields.getField("stringArrayListField").getGenericType() + + def "reflects Class"() { + when: + def underTest = ParameterizedClass.of(ArrayList) + + then: + underTest.getRawClass() == ArrayList + underTest.getActualTypeArguments()[0] instanceof TypeVariable + } + + def "reflects ParameterizedType"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType) + + then: + underTest.getRawClass() == ArrayList + underTest.getActualTypeArguments() == [ String ] as Type[] + } + + def "gets parameterized superclass with mapped type arguments"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).getParameterizedSuperclass() + + then: + underTest.getRawClass() == AbstractList + underTest.getActualTypeArguments() == [ String ] as Type[] + } + + def "gets parameterized interfaces with mapped type arguments"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).getParameterizedInterfaces() + + then: + expect underTest, arrayContainingInAnyOrder( + matchesParameterizedClass(List, [ String ] as Type[]), + matchesParameterizedClass(Cloneable, [ ] as Type[]), + matchesParameterizedClass(RandomAccess, [ ] as Type[]), + matchesParameterizedClass(Serializable, [ ] as Type[]), + ) + } + + def "finds parameterized interface with mapped type arguments"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).findParameterizedSuperclass(List) + + then: + underTest.isPresent() + underTest.get().getRawClass() == List + underTest.get().getActualTypeArguments() == [ String ] as Type[] + } + + def "finds parameterized interface of superclass with mapped type arguments"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).findParameterizedSuperclass(Collection) + + then: + underTest.isPresent() + underTest.get().getRawClass() == Collection + underTest.get().getActualTypeArguments() == [ String ] as Type[] + } + + def "does not find parameterized superclass that type does not extend"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).findParameterizedSuperclass(C) + + then: + !underTest.isPresent() + } + + def "does not find parameterized interface that type does not implement"() { + when: + def underTest = ParameterizedClass.of(stringArrayListType).findParameterizedSuperclass(I) + + then: + !underTest.isPresent() + } + + interface I { } + abstract class C { } + + static class TestFields { + public static List stringListField + public static ArrayList stringArrayListField + } + + static Matcher matchesParameterizedClass(Class rawClass, Type[] typeArguments) { + return new TypeSafeMatcher() { + @Override + boolean matchesSafely(ParameterizedClass item) { + item != null && item.getRawClass() == rawClass && Arrays.equals(item.getActualTypeArguments(), typeArguments) + } + + @Override + void describeTo(Description description) { + description + .appendText("a ParameterizedClass with raw type ") + .appendValue(rawClass) + .appendText(" and type parameters ") + .appendValue(typeArguments) + } + } + } +} diff --git a/instrumentation-api-annotation-support/src/test/java/io/opentelemetry/instrumentation/api/annotation/support/CustomAnnotation.java b/instrumentation-api-annotation-support/src/test/java/io/opentelemetry/instrumentation/api/annotation/support/CustomAnnotation.java new file mode 100644 index 000000000000..b17a1def6958 --- /dev/null +++ b/instrumentation-api-annotation-support/src/test/java/io/opentelemetry/instrumentation/api/annotation/support/CustomAnnotation.java @@ -0,0 +1,14 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.annotation.support; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface CustomAnnotation { + String value(); +} diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/build.gradle.kts b/instrumentation/opentelemetry-annotations-1.0/javaagent/build.gradle.kts index 0e92360d0d56..763e68fff0d3 100644 --- a/instrumentation/opentelemetry-annotations-1.0/javaagent/build.gradle.kts +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/build.gradle.kts @@ -7,6 +7,8 @@ plugins { val versions: Map by project dependencies { + compileOnly(project(":instrumentation-api-annotation-support")) + compileOnly(project(":javaagent-tooling")) // this instrumentation needs to do similar shading dance as opentelemetry-api-1.0 because @@ -16,10 +18,14 @@ dependencies { compileOnly(project(path = ":opentelemetry-ext-annotations-shaded-for-instrumenting", configuration = "shadow")) testImplementation("io.opentelemetry:opentelemetry-extension-annotations") + testImplementation(project(":instrumentation-api-annotation-support")) testImplementation("net.bytebuddy:byte-buddy:${versions["net.bytebuddy"]}") } tasks { + compileTestJava { + options.compilerArgs.add("-parameters") + } named("test") { jvmArgs("-Dotel.instrumentation.opentelemetry-annotations.exclude-methods=io.opentelemetry.test.annotation.TracedWithSpan[ignored]") } diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanAttributeBinder.java b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanAttributeBinder.java new file mode 100644 index 000000000000..0663bfc7064a --- /dev/null +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanAttributeBinder.java @@ -0,0 +1,79 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.otelannotations; + +import io.opentelemetry.instrumentation.api.annotation.support.AnnotationReflectionHelper; +import io.opentelemetry.instrumentation.api.annotation.support.AttributeBindings; +import io.opentelemetry.instrumentation.api.annotation.support.BaseAttributeBinder; +import io.opentelemetry.instrumentation.api.caching.Cache; +import java.lang.annotation.Annotation; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.function.Function; +import org.checkerframework.checker.nullness.qual.Nullable; + +class WithSpanAttributeBinder extends BaseAttributeBinder { + + private static final Cache bindings = + Cache.newBuilder().setWeakKeys().build(); + private static final Class spanAttributeAnnotation; + private static final Function spanAttributeValueFunction; + + static { + ClassLoader classLoader = WithSpanAttributeBinder.class.getClassLoader(); + spanAttributeAnnotation = + AnnotationReflectionHelper.forNameOrNull( + classLoader, "io.opentelemetry.extension.annotations.SpanAttribute"); + if (spanAttributeAnnotation != null) { + spanAttributeValueFunction = resolveSpanAttributeValue(spanAttributeAnnotation); + } else { + spanAttributeValueFunction = null; + } + } + + private static Function resolveSpanAttributeValue( + Class spanAttributeAnnotation) { + try { + return AnnotationReflectionHelper.bindAnnotationElementMethod( + MethodHandles.lookup(), spanAttributeAnnotation, "value", String.class); + } catch (Throwable exception) { + return annotation -> ""; + } + } + + @Override + public AttributeBindings bind(Method method) { + return spanAttributeAnnotation != null + ? bindings.computeIfAbsent(method, super::bind) + : EmptyAttributeBindings.INSTANCE; + } + + @Override + protected @Nullable String[] attributeNamesForParameters(Method method, Parameter[] parameters) { + String[] attributeNames = new String[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + attributeNames[i] = attributeName(parameters[i]); + } + return attributeNames; + } + + @Nullable + private static String attributeName(Parameter parameter) { + Annotation annotation = parameter.getDeclaredAnnotation(spanAttributeAnnotation); + if (annotation == null) { + return null; + } + String value = spanAttributeValueFunction.apply(annotation); + if (!value.isEmpty()) { + return value; + } else if (parameter.isNamePresent()) { + return parameter.getName(); + } else { + return null; + } + } +} diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanInstrumentation.java b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanInstrumentation.java index 3a4a3056e639..0ab0d32db20d 100644 --- a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanInstrumentation.java +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanInstrumentation.java @@ -7,11 +7,13 @@ import static io.opentelemetry.javaagent.instrumentation.otelannotations.WithSpanTracer.tracer; import static net.bytebuddy.matcher.ElementMatchers.declaresMethod; +import static net.bytebuddy.matcher.ElementMatchers.hasParameters; import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith; import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy; import static net.bytebuddy.matcher.ElementMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.none; import static net.bytebuddy.matcher.ElementMatchers.not; +import static net.bytebuddy.matcher.ElementMatchers.whereAny; import application.io.opentelemetry.extension.annotations.WithSpan; import io.opentelemetry.api.trace.SpanKind; @@ -40,12 +42,18 @@ public class WithSpanInstrumentation implements TypeInstrumentation { "otel.instrumentation.opentelemetry-annotations.exclude-methods"; private final ElementMatcher.Junction annotatedMethodMatcher; + private final ElementMatcher.Junction annotatedParametersMatcher; // this matcher matches all methods that should be excluded from transformation private final ElementMatcher.Junction excludedMethodsMatcher; WithSpanInstrumentation() { annotatedMethodMatcher = isAnnotatedWith(named("application.io.opentelemetry.extension.annotations.WithSpan")); + annotatedParametersMatcher = + hasParameters( + whereAny( + isAnnotatedWith( + named("application.io.opentelemetry.extension.annotations.SpanAttribute")))); excludedMethodsMatcher = configureExcludedMethods(); } @@ -56,9 +64,23 @@ public ElementMatcher typeMatcher() { @Override public void transform(TypeTransformer transformer) { + ElementMatcher.Junction tracedMethods = + annotatedMethodMatcher.and(not(excludedMethodsMatcher)); + + ElementMatcher.Junction tracedMethodsWithParameters = + tracedMethods.and(annotatedParametersMatcher); + ElementMatcher.Junction tracedMethodsWithoutParameters = + tracedMethods.and(not(annotatedParametersMatcher)); + transformer.applyAdviceToMethod( - annotatedMethodMatcher.and(not(excludedMethodsMatcher)), + tracedMethodsWithoutParameters, WithSpanInstrumentation.class.getName() + "$WithSpanAdvice"); + + // Only apply advice for tracing parameters as attributes if any of the parameters are annotated + // with @SpanAttribute to avoid unnecessarily copying the arguments into an array. + transformer.applyAdviceToMethod( + tracedMethodsWithParameters, + WithSpanInstrumentation.class.getName() + "$WithSpanAttributesAdvice"); } /* @@ -102,7 +124,48 @@ public static void onEnter( // don't create a nested span if you're not supposed to. if (tracer().shouldStartSpan(current, kind)) { - context = tracer().startSpan(current, applicationAnnotation, method, kind); + context = tracer().startSpan(current, applicationAnnotation, method, kind, null); + scope = context.makeCurrent(); + } + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void stopSpan( + @Advice.Origin Method method, + @Advice.Local("otelContext") Context context, + @Advice.Local("otelScope") Scope scope, + @Advice.Return(typing = Assigner.Typing.DYNAMIC, readOnly = false) Object returnValue, + @Advice.Thrown Throwable throwable) { + if (scope == null) { + return; + } + scope.close(); + + if (throwable != null) { + tracer().endExceptionally(context, throwable); + } else { + returnValue = tracer().end(context, method.getReturnType(), returnValue); + } + } + } + + @SuppressWarnings("unused") + public static class WithSpanAttributesAdvice { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter( + @Advice.Origin Method method, + @Advice.AllArguments(typing = Assigner.Typing.DYNAMIC) Object[] args, + @Advice.Local("otelContext") Context context, + @Advice.Local("otelScope") Scope scope) { + WithSpan applicationAnnotation = method.getAnnotation(WithSpan.class); + + SpanKind kind = tracer().extractSpanKind(applicationAnnotation); + Context current = Java8BytecodeBridge.currentContext(); + + // don't create a nested span if you're not supposed to. + if (tracer().shouldStartSpan(current, kind)) { + context = tracer().startSpan(current, applicationAnnotation, method, kind, args); scope = context.makeCurrent(); } } diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanTracer.java b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanTracer.java index f718215d440e..519ee5aa0cff 100644 --- a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanTracer.java +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/otelannotations/WithSpanTracer.java @@ -7,8 +7,10 @@ import application.io.opentelemetry.extension.annotations.WithSpan; import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.annotation.support.AttributeBindings; import io.opentelemetry.instrumentation.api.tracer.BaseTracer; import io.opentelemetry.instrumentation.api.tracer.SpanNames; import io.opentelemetry.instrumentation.api.tracer.async.AsyncSpanEndStrategies; @@ -26,15 +28,22 @@ public static WithSpanTracer tracer() { private static final Logger logger = LoggerFactory.getLogger(WithSpanTracer.class); + private final WithSpanAttributeBinder attributeBinder = new WithSpanAttributeBinder(); private final AsyncSpanEndStrategies asyncSpanEndStrategies = AsyncSpanEndStrategies.getInstance(); public Context startSpan( - Context parentContext, WithSpan applicationAnnotation, Method method, SpanKind kind) { - Span span = + Context parentContext, + WithSpan applicationAnnotation, + Method method, + SpanKind kind, + Object[] args) { + + SpanBuilder spanBuilder = spanBuilder( - parentContext, spanNameForMethodWithAnnotation(applicationAnnotation, method), kind) - .startSpan(); + parentContext, spanNameForMethodWithAnnotation(applicationAnnotation, method), kind); + Span span = withSpanAttributes(spanBuilder, method, args).startSpan(); + if (kind == SpanKind.SERVER) { return withServerSpan(parentContext, span); } @@ -75,6 +84,16 @@ public static SpanKind toAgentOrNull( } } + public SpanBuilder withSpanAttributes(SpanBuilder spanBuilder, Method method, Object[] args) { + if (args != null && args.length > 0) { + AttributeBindings bindings = attributeBinder.bind(method); + if (!bindings.isEmpty()) { + bindings.apply(spanBuilder::setAttribute, args); + } + } + return spanBuilder; + } + /** * Denotes the end of the invocation of the traced method with a successful result which will end * the span stored in the passed {@code context}. If the method returned a value representing an diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/groovy/WithSpanInstrumentationTest.groovy b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/groovy/WithSpanInstrumentationTest.groovy index 8166cf7f807f..43ac8d4cd3d5 100644 --- a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/groovy/WithSpanInstrumentationTest.groovy +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/groovy/WithSpanInstrumentationTest.groovy @@ -419,4 +419,24 @@ class WithSpanInstrumentationTest extends AgentInstrumentationSpecification { } } } + + def "should capture attributes"() { + setup: + new TracedWithSpan().withSpanAttributes("foo", "bar", null, "baz") + + expect: + assertTraces(1) { + trace(0, 1) { + span(0) { + name "TracedWithSpan.withSpanAttributes" + kind INTERNAL + hasNoParent() + attributes { + "implicitName" "foo" + "explicitName" "bar" + } + } + } + } + } } diff --git a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/java/io/opentelemetry/test/annotation/TracedWithSpan.java b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/java/io/opentelemetry/test/annotation/TracedWithSpan.java index bad95dfa9ae4..872fee6d043e 100644 --- a/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/java/io/opentelemetry/test/annotation/TracedWithSpan.java +++ b/instrumentation/opentelemetry-annotations-1.0/javaagent/src/test/java/io/opentelemetry/test/annotation/TracedWithSpan.java @@ -6,6 +6,7 @@ package io.opentelemetry.test.annotation; import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.extension.annotations.SpanAttribute; import io.opentelemetry.extension.annotations.WithSpan; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -57,6 +58,16 @@ public String innerClient() { return "hello!"; } + @WithSpan + public String withSpanAttributes( + @SpanAttribute String implicitName, + @SpanAttribute("explicitName") String parameter, + @SpanAttribute("nullAttribute") String nullAttribute, + String notTraced) { + + return "hello!"; + } + @WithSpan public CompletionStage completionStage(CompletableFuture future) { return future; diff --git a/instrumentation/spring/spring-boot-autoconfigure/README.md b/instrumentation/spring/spring-boot-autoconfigure/README.md index eb5dc53da6eb..e1a10bdf5ff6 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/README.md +++ b/instrumentation/spring/spring-boot-autoconfigure/README.md @@ -164,15 +164,20 @@ Provides auto-configurations for the OpenTelemetry WebClient ExchangeFilter defi #### Manual Instrumentation Support - @WithSpan -This feature uses spring-aop to wrap methods annotated with `@WithSpan` in a span. +This feature uses spring-aop to wrap methods annotated with `@WithSpan` in a span. The arguments +to the method can be captured as attributed on the created span by annotating the method +parameters with `@SpanAttribute`. -Note - This annotation can only be applied to bean methods managed by the spring application context. Check out [spring-aop](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop) to learn more about aspect weaving in spring. +Note - This annotation can only be applied to bean methods managed by the spring application +context. Check out [spring-aop](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop) +to learn more about aspect weaving in spring. ##### Usage ```java import org.springframework.stereotype.Component; +import io.opentelemetry.extension.annotations.SpanAttribute; import io.opentelemetry.extension.annotations.WithSpan; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanKind; @@ -197,6 +202,9 @@ public class TracedClass { @WithSpan(kind = SpanKind.CLIENT) public void tracedClientSpan() { } + + public void tracedMethodWithAttribute(@SpanAttribute("attributeName") String parameter) { + } } ``` @@ -374,10 +382,10 @@ If an exporter is present in the classpath during runtime and a spring bean of t | Feature | Property | Default Value | ConditionalOnClass | |------------------|------------------------------------------|---------------|------------------------| -| spring-web | otel.springboot.httpclients.enabled | true | RestTemplate | -| spring-webmvc | otel.springboot.httpclients.enabled | true | OncePerRequestFilter | -| spring-webflux | otel.springboot.httpclients.enabled | true | WebClient | -| @WithSpan | otel.springboot.aspects.enabled | true | WithSpan, Aspect | +| spring-web | otel.springboot.httpclients.enabled | true | RestTemplate | +| spring-webmvc | otel.springboot.httpclients.enabled | true | OncePerRequestFilter | +| spring-webflux | otel.springboot.httpclients.enabled | true | WebClient | +| @WithSpan | otel.springboot.aspects.enabled | true | WithSpan, Aspect | | Otlp Exporter | otel.exporter.otlp.enabled | true | OtlpGrpcSpanExporter | | Jaeger Exporter | otel.exporter.jaeger.enabled | true | JaegerGrpcSpanExporter | | Zipkin Exporter | otel.exporter.zipkin.enabled | true | ZipkinSpanExporter | diff --git a/instrumentation/spring/spring-boot-autoconfigure/build.gradle.kts b/instrumentation/spring/spring-boot-autoconfigure/build.gradle.kts index 63a8b1c9bdd4..f8c66a6c01a4 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/build.gradle.kts +++ b/instrumentation/spring/spring-boot-autoconfigure/build.gradle.kts @@ -8,6 +8,8 @@ group = "io.opentelemetry.instrumentation" val versions: Map by project dependencies { + implementation(project(":instrumentation-api-annotation-support")) + implementation("org.springframework.boot:spring-boot-autoconfigure:${versions["org.springframework.boot"]}") annotationProcessor("org.springframework.boot:spring-boot-autoconfigure-processor:${versions["org.springframework.boot"]}") implementation("javax.validation:validation-api:2.0.1.Final") @@ -49,4 +51,9 @@ dependencies { testImplementation("io.opentelemetry:opentelemetry-exporter-zipkin") testImplementation("io.grpc:grpc-api:1.30.2") testImplementation("io.grpc:grpc-netty-shaded:1.30.2") + testImplementation(project(":instrumentation-api-annotation-support")) +} + +tasks.compileTestJava { + options.compilerArgs.add("-parameters") } diff --git a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/TraceAspectAutoConfiguration.java b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/TraceAspectAutoConfiguration.java index 44908c6fb8c3..2d6e11f4ae94 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/TraceAspectAutoConfiguration.java +++ b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/TraceAspectAutoConfiguration.java @@ -9,10 +9,13 @@ import io.opentelemetry.extension.annotations.WithSpan; import org.aspectj.lang.annotation.Aspect; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; /** Configures {@link WithSpanAspect} to trace bean methods annotated with {@link WithSpan}. */ @Configuration @@ -22,7 +25,20 @@ public class TraceAspectAutoConfiguration { @Bean - public WithSpanAspect withSpanAspect(OpenTelemetry openTelemetry) { - return new WithSpanAspect(openTelemetry); + @ConditionalOnMissingBean + public ParameterNameDiscoverer parameterNameDiscoverer() { + return new DefaultParameterNameDiscoverer(); + } + + @Bean + public WithSpanAspectAttributeBinder withSpanAspectAttributeBinder( + ParameterNameDiscoverer parameterNameDiscoverer) { + return new WithSpanAspectAttributeBinder(parameterNameDiscoverer); + } + + @Bean + public WithSpanAspect withSpanAspect( + OpenTelemetry openTelemetry, WithSpanAspectAttributeBinder withSpanAspectAttributeBinder) { + return new WithSpanAspect(openTelemetry, withSpanAspectAttributeBinder); } } diff --git a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspect.java b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspect.java index a6715966ae31..b7944fe7a65e 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspect.java +++ b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspect.java @@ -29,8 +29,9 @@ public class WithSpanAspect { private final WithSpanAspectTracer tracer; - public WithSpanAspect(OpenTelemetry openTelemetry) { - tracer = new WithSpanAspectTracer(openTelemetry); + public WithSpanAspect( + OpenTelemetry openTelemetry, WithSpanAspectAttributeBinder withSpanAspectAttributeBinder) { + tracer = new WithSpanAspectTracer(openTelemetry, withSpanAspectAttributeBinder); } @Around("@annotation(io.opentelemetry.extension.annotations.WithSpan)") @@ -44,7 +45,7 @@ public Object traceMethod(ProceedingJoinPoint pjp) throws Throwable { return pjp.proceed(); } - Context context = tracer.startSpan(parentContext, withSpan, method); + Context context = tracer.startSpan(parentContext, withSpan, method, pjp); try (Scope ignored = context.makeCurrent()) { Object result = pjp.proceed(); return tracer.end(context, method.getReturnType(), result); diff --git a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectAttributeBinder.java b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectAttributeBinder.java new file mode 100644 index 000000000000..e8ff37452e5c --- /dev/null +++ b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectAttributeBinder.java @@ -0,0 +1,65 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.spring.autoconfigure.aspects; + +import io.opentelemetry.extension.annotations.SpanAttribute; +import io.opentelemetry.instrumentation.api.annotation.support.AttributeBindings; +import io.opentelemetry.instrumentation.api.annotation.support.BaseAttributeBinder; +import io.opentelemetry.instrumentation.api.caching.Cache; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.core.ParameterNameDiscoverer; + +public class WithSpanAspectAttributeBinder extends BaseAttributeBinder { + + private static final Cache bindings = + Cache.newBuilder().setWeakKeys().build(); + + private final ParameterNameDiscoverer parameterNameDiscoverer; + + public WithSpanAspectAttributeBinder(ParameterNameDiscoverer parameterNameDiscoverer) { + this.parameterNameDiscoverer = parameterNameDiscoverer; + } + + @Override + public AttributeBindings bind(Method method) { + return bindings.computeIfAbsent(method, super::bind); + } + + @Override + protected @Nullable String[] attributeNamesForParameters(Method method, Parameter[] parameters) { + String[] parameterNames = parameterNameDiscoverer.getParameterNames(method); + String[] attributeNames = new String[parameters.length]; + + for (int i = 0; i < parameters.length; i++) { + attributeNames[i] = attributeName(parameters[i], parameterNames, i); + } + return attributeNames; + } + + @Nullable + private static String attributeName(Parameter parameter, String[] parameterNames, int index) { + SpanAttribute annotation = parameter.getDeclaredAnnotation(SpanAttribute.class); + if (annotation == null) { + return null; + } + String value = annotation.value(); + if (!value.isEmpty()) { + return value; + } + if (parameterNames != null && parameterNames.length >= index) { + String parameterName = parameterNames[index]; + if (parameterName != null && !parameterName.isEmpty()) { + return parameterName; + } + } + if (parameter.isNamePresent()) { + return parameter.getName(); + } + return null; + } +} diff --git a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTracer.java b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTracer.java index 96e74fe63f5c..b98e87daab2c 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTracer.java +++ b/instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTracer.java @@ -7,21 +7,27 @@ import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; import io.opentelemetry.context.Context; import io.opentelemetry.extension.annotations.WithSpan; +import io.opentelemetry.instrumentation.api.annotation.support.AttributeBindings; import io.opentelemetry.instrumentation.api.tracer.BaseTracer; import io.opentelemetry.instrumentation.api.tracer.SpanNames; import io.opentelemetry.instrumentation.api.tracer.async.AsyncSpanEndStrategies; import io.opentelemetry.instrumentation.api.tracer.async.AsyncSpanEndStrategy; import java.lang.reflect.Method; +import org.aspectj.lang.JoinPoint; class WithSpanAspectTracer extends BaseTracer { + private final WithSpanAspectAttributeBinder withSpanAspectAttributeBinder; private final AsyncSpanEndStrategies asyncSpanEndStrategies = AsyncSpanEndStrategies.getInstance(); - WithSpanAspectTracer(OpenTelemetry openTelemetry) { + WithSpanAspectTracer( + OpenTelemetry openTelemetry, WithSpanAspectAttributeBinder withSpanAspectAttributeBinder) { super(openTelemetry); + this.withSpanAspectAttributeBinder = withSpanAspectAttributeBinder; } @Override @@ -29,9 +35,11 @@ protected String getInstrumentationName() { return "io.opentelemetry.spring-boot-autoconfigure-aspect"; } - Context startSpan(Context parentContext, WithSpan annotation, Method method) { - Span span = - spanBuilder(parentContext, spanName(annotation, method), annotation.kind()).startSpan(); + Context startSpan( + Context parentContext, WithSpan annotation, Method method, JoinPoint joinPoint) { + SpanBuilder spanBuilder = + spanBuilder(parentContext, spanName(annotation, method), annotation.kind()); + Span span = withSpanAttributes(spanBuilder, method, joinPoint).startSpan(); switch (annotation.kind()) { case SERVER: return withServerSpan(parentContext, span); @@ -50,6 +58,15 @@ private static String spanName(WithSpan annotation, Method method) { return spanName; } + public SpanBuilder withSpanAttributes( + SpanBuilder spanBuilder, Method method, JoinPoint joinPoint) { + AttributeBindings bindings = withSpanAspectAttributeBinder.bind(method); + if (!bindings.isEmpty()) { + bindings.apply(spanBuilder::setAttribute, joinPoint.getArgs()); + } + return spanBuilder; + } + /** * Denotes the end of the invocation of the traced method with a successful result which will end * the span stored in the passed {@code context}. If the method returned a value representing an diff --git a/instrumentation/spring/spring-boot-autoconfigure/src/test/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTest.java b/instrumentation/spring/spring-boot-autoconfigure/src/test/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTest.java index b7339c80a5bd..0be09127b143 100644 --- a/instrumentation/spring/spring-boot-autoconfigure/src/test/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTest.java +++ b/instrumentation/spring/spring-boot-autoconfigure/src/test/java/io/opentelemetry/instrumentation/spring/autoconfigure/aspects/WithSpanAspectTest.java @@ -12,11 +12,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.extension.annotations.SpanAttribute; import io.opentelemetry.extension.annotations.WithSpan; import io.opentelemetry.instrumentation.testing.junit.LibraryInstrumentationExtension; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.data.StatusData; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -27,6 +32,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; +import org.springframework.core.ParameterNameDiscoverer; /** Spring AOP Test for {@link WithSpanAspect}. */ public class WithSpanAspectTest { @@ -68,6 +74,17 @@ public CompletionStage testAsyncCompletionStage(CompletionStage public CompletableFuture testAsyncCompletableFuture(CompletableFuture stage) { return stage; } + + @WithSpan + public String withSpanAttributes( + @SpanAttribute String discoveredName, + @SpanAttribute String implicitName, + @SpanAttribute("explicitName") String parameter, + @SpanAttribute("nullAttribute") String nullAttribute, + String notTraced) { + + return "hello!"; + } } private WithSpanTester withSpanTester; @@ -75,7 +92,21 @@ public CompletableFuture testAsyncCompletableFuture(CompletableFuture constructor) { + return null; + } + }; + WithSpanAspectAttributeBinder attributeBinder = + new WithSpanAspectAttributeBinder(parameterNameDiscoverer); + WithSpanAspect aspect = new WithSpanAspect(testing.getOpenTelemetry(), attributeBinder); factory.addAspect(aspect); withSpanTester = factory.getProxy(); @@ -197,6 +228,31 @@ void suppressServerSpan() throws Throwable { parentSpan -> parentSpan.hasName("parent").hasKind(SERVER))); } + @Test + @DisplayName("") + void withSpanAttributes() throws Throwable { + // when + testing.runWithSpan( + "parent", () -> withSpanTester.withSpanAttributes("foo", "bar", "baz", null, "fizz")); + + // then + List> traces = testing.waitForTraces(1); + assertThat(traces) + .hasTracesSatisfyingExactly( + trace -> + trace.hasSpansSatisfyingExactly( + parentSpan -> parentSpan.hasName("parent").hasKind(INTERNAL), + span -> + span.hasName("WithSpanTester.withSpanAttributes") + .hasKind(INTERNAL) + .hasAttributes( + Attributes.of( + AttributeKey.stringKey("discoveredName"), "foo", + AttributeKey.stringKey("implicitName"), "bar", + AttributeKey.stringKey("explicitName"), "baz")) + .hasParentSpanId(traces.get(0).get(0).getSpanId()))); + } + @Nested @DisplayName("with a method annotated with @WithSpan returns CompletionStage") class WithCompletionStage { diff --git a/javaagent-bootstrap/build.gradle.kts b/javaagent-bootstrap/build.gradle.kts index 762f1e1890ab..6bda626a69f6 100644 --- a/javaagent-bootstrap/build.gradle.kts +++ b/javaagent-bootstrap/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { compileOnly("io.opentelemetry:opentelemetry-sdk") implementation(project(":instrumentation-api")) + implementation(project(":instrumentation-api-annotation-support")) implementation(project(":javaagent-instrumentation-api")) implementation("org.slf4j:slf4j-api") diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ed43a405175..18164b1c1aeb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include(":bom-alpha") include(":instrumentation-api") include(":instrumentation-api-caching") include(":javaagent-instrumentation-api") +include(":instrumentation-api-annotation-support") // misc include(":dependencyManagement")