Skip to content

Commit

Permalink
Merge pull request #34 from levigo/feature/opentelemetry
Browse files Browse the repository at this point in the history
Feature/opentelemetry
  • Loading branch information
welschsn authored Jan 5, 2024
2 parents d762773 + e8c3996 commit 94142dc
Show file tree
Hide file tree
Showing 14 changed files with 855 additions and 156 deletions.
34 changes: 34 additions & 0 deletions neverpile-commons-opentelemetry/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.neverpile.commons</groupId>
<artifactId>neverpile-commons</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>neverpile-commons-opentelemetry</artifactId>

<properties>
<opentelemetry.version>1.33.0</opentelemetry.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>${opentelemetry.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.neverpile.common.opentelemetry;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.function.BiConsumer;
import java.util.function.Function;

/**
* Parameter annotation used on methods annotated with {@link TraceInvocation} used to indicate that the
* given method parameter should be used as the value of a span attribute.
*/
@Target({
ElementType.PARAMETER, ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
public @interface Attribute {
class NoopMapper implements Function<Object, Object> {
@Override
public Object apply(final Object t) {
return t; // just a dummy
}
}

/**
* The name of the attribute for a traced parameter.
* @return The name of the attribute.
*/
String name() default "";

/**
* An optional implementation of a {@link Function} used to map from the argument value to the attribute
* value.
* @return Function to map a non-standard value for tracing.
*/
Class<? extends Function<? extends Object, ? extends Object>> valueAdapter() default NoopMapper.class;

interface AttributeExtractor<V> {
void extract(V value, BiConsumer<String, Object> attributeCreator);
}

public static class NoopExtractor implements AttributeExtractor<Object> {
@Override
public void extract(final Object value, final BiConsumer<String, Object> a) {
// just a dummy
}
}

/**
* An optional implementation of a {@link Function} used to map from the argument value to the attribute
* value.
* @return Function to map a non-standard value for tracing.
*/
Class<? extends AttributeExtractor<?>> attributeExtractor() default NoopExtractor.class;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.neverpile.common.opentelemetry;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation used on methods to indicate that a new OpenTelemetry span should be created around the
* method invocation. Span attributes can be assigned from method parameters using {@link Attribute}
*/
@Target({
ElementType.METHOD, ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
public @interface TraceInvocation {
String operationName() default "";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.neverpile.common.opentelemetry.aspect;

import java.lang.reflect.Parameter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;

import com.neverpile.common.opentelemetry.Attribute;
import com.neverpile.common.opentelemetry.Attribute.AttributeExtractor;
import com.neverpile.common.opentelemetry.TraceInvocation;

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

import jakarta.annotation.PostConstruct;

/**
* An aspect used to create OpenTelemetry spans for calls to methods annotated with
* {@link TraceInvocation}.
*/
@Aspect
public class OpenTelemetryAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(OpenTelemetryAspect.class);

private final Tracer tracer;

public OpenTelemetryAspect(Tracer tracer) {
this.tracer = tracer;
}

private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

private final Map<Class<? extends Function<Object, Object>>, Function<Object, Object>> valueAdapterCache = new ConcurrentHashMap<>();

private final Map<Class<? extends AttributeExtractor<Object>>, AttributeExtractor<Object>> attributeExtractorCache = new ConcurrentHashMap<>();

@PostConstruct
public void logActivation() {
LOGGER.info("OpenTelemetry Tracer found - tracing of methods annotated with @TraceInvocation enabled");
}

@Around("execution (@com.neverpile.common.opentelemetry.TraceInvocation * *.*(..))")
public Object newSpanAround(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Object[] args = joinPoint.getArgs();

Span span = startSpan(signature, joinPoint.getThis());

resolveParameters(signature, args, span);

try (Scope scope = span.makeCurrent()) {
return joinPoint.proceed(args);
} catch (Throwable ex) {
span.setStatus(StatusCode.ERROR, ex.getMessage());
span.recordException(ex);
throw ex;
} finally {
span.end();
}
}

private void resolveParameters(final MethodSignature signature, final Object[] args, final Span span) {
Parameter[] parameters = signature.getMethod().getParameters();
for (int i = 0; i < parameters.length; i++) {
if (parameters[i].getAnnotation(Attribute.class) != null) {
setupAttribute(signature, i, parameters[i], args[i], span);
}
}
}

@SuppressWarnings("unchecked")
private void setupAttribute(final MethodSignature signature, final int parameterIndex, final Parameter parameter,
final Object arg, final Span span) {
Attribute annotation = parameter.getAnnotation(Attribute.class);

// if we have a value extractor, use it
if (!annotation.attributeExtractor().equals(Attribute.NoopExtractor.class)) {
if (!annotation.valueAdapter().equals(Attribute.NoopMapper.class))
LOGGER.warn("@Attribute.attributeExtractor and @Attribute.valueAdapter are mutually exclusive");
attributeExtractorCache //
.computeIfAbsent((Class<? extends AttributeExtractor<Object>>) annotation.attributeExtractor(),
c -> (AttributeExtractor<Object>) BeanUtils.instantiateClass(c)) //
.extract(arg, (key, value) -> setAttribute(span, key, value));

} else {
// otherwise build a single attribute
String attributeKey = StringUtils.hasText(annotation.name())
? annotation.name()
: findParameterName(signature, parameterIndex);
Object value = arg;

// if we have a value mapper, apply it to the value
if (!annotation.valueAdapter().equals(Attribute.NoopMapper.class)) {
value = valueAdapterCache //
.computeIfAbsent((Class<? extends Function<Object, Object>>) annotation.valueAdapter(),
c -> (Function<Object, Object>) BeanUtils.instantiateClass(c)) //
.apply(value);
}

setAttribute(span, attributeKey, value);
}
}

private String findParameterName(final MethodSignature signature, final int parameterIndex) {
String[] parameterNames = parameterNameDiscoverer.getParameterNames(signature.getMethod());
if (null == parameterNames || parameterNames.length <= parameterIndex) {
LOGGER.warn("Can't determine a parameter name for {}", signature.getMethod());
return "<unknown>";
}

return parameterNames[parameterIndex];
}

private void setAttribute(final Span span, final String key, final Object value) {
if (null == value)
span.setAttribute(key, "<NULL>");
else if (value instanceof Long longValue)
span.setAttribute(key, longValue);
else if (value instanceof Double doubleValue)
span.setAttribute(key, doubleValue);
else if (value instanceof Integer integerValue)
span.setAttribute(key, integerValue);
else if (value instanceof Boolean booleanValue)
span.setAttribute(key, booleanValue);
else
span.setAttribute(key, value.toString());
}

private Span startSpan(final MethodSignature signature, final Object target) {
String operationName = getOperationName(signature, target);
// NOTE: setParent(...) is not required; `Span.current()` is automatically added as the parent
return tracer.spanBuilder(operationName).startSpan();
}

private String getOperationName(final MethodSignature signature, final Object target) {
String operationName;
TraceInvocation newSpanAnnotation = signature.getMethod().getAnnotation(TraceInvocation.class);
if (!StringUtils.hasText(newSpanAnnotation.operationName())) {
operationName = (target != null
? ClassUtils.getUserClass(target).getSimpleName()
: signature.getDeclaringType().getSimpleName()) + "." + signature.getName();
} else {
operationName = newSpanAnnotation.operationName();
}

return operationName;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.neverpile.common.opentelemetry.aspect;

import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

import io.opentelemetry.api.trace.Tracer;

@Configuration
// Configure as late as possible, after any tracer has been created
@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE)
@ConditionalOnBean(Tracer.class)
public class OpenTelemetryAspectAutoConfiguration {
@Bean
public OpenTelemetryAspect openTelemetryAspect(Tracer tracer) {
return new OpenTelemetryAspect(tracer);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.neverpile.common.opentelemetry.aspect.OpenTelemetryAspectAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.neverpile.common.opentelemetry;

import static org.assertj.core.api.Assertions.*;

import java.util.HashMap;


import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class OpenTelemetryAspectTest {
@Autowired
TestTracer tt;

@Autowired
SomeTracedService service;

@BeforeEach
public void reset() {
service.reset();
tt.getFinishedSpans().clear();
}

@Test
void testNoParams() {
service.noParams();

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getOperationName()).isEqualTo("SomeTracedService.noParams");
assertThat(tt.getFinishedSpans().get(0).getAttributes()).isEmpty();
}

@Test
void testRenamedOp() {
service.weirdMethodNameReplacedByOperationName();

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getOperationName()).isEqualTo("foo");
assertThat(tt.getFinishedSpans().get(0).getAttributes()).isEmpty();
}

@Test
void testParams() {
service.someParams("hello", 4711, true);

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getAttributes()) //
.containsEntry("foo", "hello") //
.containsEntry("bar", 4711L) //
.containsEntry("baz", true);
}

@Test
void testSelfNamingParam() {
service.selfNamingParam("hello");

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getAttributes()) //
.containsEntry("foo", "hello"); //
}

@Test
void testPartialParams() {
service.someMoreParams("hello", 4711, true);

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getAttributes()).containsEntry("foo", "hello").hasSize(1);
}

@Test
void testAttributeExtractor() {
HashMap<String, Object> m = new HashMap<>();
m.put("foo", "bar");
m.put("bar", 1234L);
m.put("baz", 1234.56d);
m.put("buz", false);

service.parameterWithExtractor(m);

assertThat(service.getInvocationCounter()).isEqualTo(1);
assertThat(tt.getFinishedSpans()).hasSize(1);
assertThat(tt.getFinishedSpans().get(0).getAttributes()) //
.containsEntry("foo", "bar") //
.containsEntry("bar", 1234L) //
.containsEntry("baz", 1234.56d) //
.containsEntry("buz", false);
}
}
Loading

0 comments on commit 94142dc

Please sign in to comment.