diff --git a/bootstrap/src/main/java/com/splunk/opentelemetry/javaagent/bootstrap/nocode/NocodeRules.java b/bootstrap/src/main/java/com/splunk/opentelemetry/javaagent/bootstrap/nocode/NocodeRules.java new file mode 100644 index 000000000..2a74ce1cc --- /dev/null +++ b/bootstrap/src/main/java/com/splunk/opentelemetry/javaagent/bootstrap/nocode/NocodeRules.java @@ -0,0 +1,77 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.javaagent.bootstrap.nocode; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class NocodeRules { + + public static final class Rule { + public final String className; + public final String methodName; + public final String spanName; // may be null - use default of "class.method" + public final String spanKind; // matches the SpanKind enum, null means default to INTERNAL + public final Map attributes; // key name to jsps + + public Rule( + String className, + String methodName, + String spanName, + String spanKind, + Map attributes) { + this.className = className; + this.methodName = methodName; + this.spanName = spanName; + this.spanKind = spanKind; + this.attributes = Collections.unmodifiableMap(new HashMap<>(attributes)); + } + + public String toString() { + return "Nocode rule: " + + className + + "." + + methodName + + ":spanName=" + + spanName + + ",attrs=" + + attributes; + } + } + + private NocodeRules() {} + + // Using className.methodName as the key + private static final HashMap name2Rule = new HashMap<>(); + + // Called by the NocodeInitializer + public static void setGlobalRules(List rules) { + for (Rule r : rules) { + name2Rule.put(r.className + "." + r.methodName, r); + } + } + + public static Iterable getGlobalRules() { + return name2Rule.values(); + } + + public static Rule find(String className, String methodName) { + return name2Rule.get(className + "." + methodName); + } +} diff --git a/instrumentation/nocode/build.gradle.kts b/instrumentation/nocode/build.gradle.kts new file mode 100644 index 000000000..61b83c985 --- /dev/null +++ b/instrumentation/nocode/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("splunk.instrumentation-conventions") + id("splunk.muzzle-conventions") +} + +dependencies { + compileOnly(project(":custom")) + compileOnly("io.opentelemetry.javaagent:opentelemetry-javaagent-tooling") + compileOnly("org.snakeyaml:snakeyaml-engine:2.8") + + add("codegen", project(":bootstrap")) +} + +tasks.withType().configureEach { + environment("SPLUNK_OTEL_INSTRUMENTATION_NOCODE_YML_FILE", "./src/test/config/nocode.yml") +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/JSPS.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/JSPS.java new file mode 100644 index 000000000..c8d132705 --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/JSPS.java @@ -0,0 +1,173 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.logging.Logger; + +/** + * JSPS stands for Java-like String-Producing Statement. A JSPS is essentially a single call in Java + * (as though it ends with a semicolon), with some limitations. Its purpose is to allow pieces of + * nocode instrumentation (attributes, span name) to be derived from the instrumentated context. + * + *

As some illustrative examples: + * + *

+ *   this.getHeaders().get("X-Custom-Header").substring(5)
+ *   param0.getDetails().getCustomerAccount().getAccountType()
+ * 
+ * + *

The limitations are: + * + *

    + *
  • no access to variables other than 'this' and 'paramN' (N indexed at 0) + *
  • no control flow (if), no local variables, basically nothing other than a single chain of + * method calls + *
  • Method calls are limited to either 0 or 1 parameters currently + *
  • Parameters must be literals and only integral (int/long), string, and boolean literals are + * currently supported + *
+ */ +public final class JSPS { + private static final Logger logger = Logger.getLogger(JSPS.class.getName()); + private static final Class[] NoParamTypes = new Class[0]; + + public static String evaluate(String jsps, Object thiz, Object[] params) { + try { + return unsafeEvaluate(jsps, thiz, params); + } catch (Throwable t) { + logger.warning("Can't evaluate {" + jsps + "}: " + t); + return null; + } + } + + // FIXME Might be nice to support escaping quotes in string literals... + private static String unsafeEvaluate(String jsps, Object thiz, Object[] params) throws Exception { + jsps = jsps.trim(); + int nextDot = jsps.indexOf('.'); + String var = jsps.substring(0, nextDot).trim(); + Object curObject = null; + if (var.equals("this")) { + curObject = thiz; + } else if (var.startsWith("param")) { + int varIndex = Integer.parseInt(var.substring("param".length())); + curObject = params[varIndex]; + } + int curIndex = nextDot; + while (curIndex < jsps.length()) { + curIndex = jsps.indexOf('.', curIndex); + while (jsps.charAt(curIndex) == '.' || Character.isWhitespace(jsps.charAt(curIndex))) { + curIndex++; + } + int openParen = jsps.indexOf('(', curIndex); + String method = jsps.substring(curIndex, openParen).trim(); + int closeParen = jsps.indexOf(')', openParen); + String paramString = jsps.substring(openParen + 1, closeParen).trim(); + if (paramString.isEmpty()) { + Method m = findMatchingMethod(curObject, method, NoParamTypes); + curObject = m.invoke(curObject); + } else { + if (paramString.startsWith("\"") && paramString.endsWith("\"")) { + String passed = paramString.substring(1, paramString.length() - 1); + Method m = findMethodWithPossibleTypes(curObject, method, String.class, Object.class); + curObject = m.invoke(curObject, passed); + } else if (paramString.equals("true") || paramString.equals("false")) { + Method m = + findMethodWithPossibleTypes( + curObject, method, boolean.class, Boolean.class, Object.class); + curObject = m.invoke(curObject, Boolean.parseBoolean(paramString)); + } else if (paramString.matches("[0-9]+")) { + try { + Method m = + findMethodWithPossibleTypes( + curObject, method, int.class, Integer.class, Object.class); + int passed = Integer.parseInt(paramString); + curObject = m.invoke(curObject, passed); + } catch (NoSuchMethodException tryLongInstead) { + Method m = + findMethodWithPossibleTypes( + curObject, method, long.class, Long.class, Object.class); + long passed = Long.parseLong(paramString); + curObject = m.invoke(curObject, passed); + } + } else { + throw new UnsupportedOperationException( + "Can't parse \"" + paramString + "\" as literal parameter"); + } + } + curIndex = closeParen + 1; + } + return curObject == null ? null : curObject.toString(); + } + + // This sequence of methods is here because: + // - we want to try a variety of parameter types to match a literal + // e.g., someMethod(5) could match someMethod(int) or someMethod(Object) + // - module rules around reflection make some "legal" things harder and require + // looking for a public class/interface matching the method to call + // e.g., a naive reflective call through + // this.getSomeHashMap().entrySet().size() + // would fail simply because the HashMap$EntrySet implementation + // is not public, even though the interface it's being call through is. + private static Method findMatchingMethod( + Object curObject, String methodName, Class[] actualParamTypes) throws NoSuchMethodException { + Method m = findMatchingMethod(methodName, curObject.getClass(), actualParamTypes); + if (m == null) { + throw new NoSuchMethodException( + "Can't find matching method for " + methodName + " on " + curObject.getClass().getName()); + } + return m; + } + + // Returns null for none found + private static Method findMatchingMethod( + String methodName, Class clazz, Class[] actualParamTypes) { + if (clazz == null) { + return null; + } + if (Modifier.isPublic(clazz.getModifiers())) { + try { + return clazz.getMethod(methodName, actualParamTypes); + } catch (NoSuchMethodException nsme) { + // keep trying + } + } + // not public, try interfaces and supertype + for (Class iface : clazz.getInterfaces()) { + Method m = findMatchingMethod(methodName, iface, actualParamTypes); + if (m != null) { + return m; + } + } + return findMatchingMethod(methodName, clazz.getSuperclass(), actualParamTypes); + } + + private static Method findMethodWithPossibleTypes( + Object curObject, String methodName, Class... paramTypesToTryInOrder) + throws NoSuchMethodException { + Class c = curObject.getClass(); + for (Class paramType : paramTypesToTryInOrder) { + try { + return findMatchingMethod(curObject, methodName, new Class[] {paramType}); + } catch (NoSuchMethodException e) { + // try next one + } + } + throw new NoSuchMethodException(methodName + " with single parameter matching given type"); + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeAttributesExtractor.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeAttributesExtractor.java new file mode 100644 index 000000000..6a901e39e --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeAttributesExtractor.java @@ -0,0 +1,60 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.incubator.semconv.code.CodeAttributesExtractor; +import io.opentelemetry.instrumentation.api.incubator.semconv.util.ClassAndMethod; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import java.util.Map; +import javax.annotation.Nullable; + +public final class NocodeAttributesExtractor + implements AttributesExtractor { + private final AttributesExtractor codeExtractor; + + public NocodeAttributesExtractor() { + codeExtractor = CodeAttributesExtractor.create(ClassAndMethod.codeAttributesGetter()); + } + + @Override + public void onStart( + AttributesBuilder attributesBuilder, Context context, NocodeMethodInvocation mi) { + codeExtractor.onStart(attributesBuilder, context, mi.getClassAndMethod()); + + Map attributes = mi.getRuleAttributes(); + for (String key : attributes.keySet()) { + String jsps = attributes.get(key); + String value = JSPS.evaluate(jsps, mi.getThiz(), mi.getParameters()); + if (value != null) { + attributesBuilder.put(key, value); + } + } + } + + @Override + public void onEnd( + AttributesBuilder attributesBuilder, + Context context, + NocodeMethodInvocation nocodeMethodInvocation, + @Nullable Void unused, + @Nullable Throwable throwable) { + codeExtractor.onEnd( + attributesBuilder, context, nocodeMethodInvocation.getClassAndMethod(), unused, throwable); + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInitializer.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInitializer.java new file mode 100644 index 000000000..9eda6599e --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInitializer.java @@ -0,0 +1,36 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import static io.opentelemetry.sdk.autoconfigure.AutoConfigureUtil.getConfig; + +import com.google.auto.service.AutoService; +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.javaagent.tooling.BeforeAgentListener; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; + +@AutoService(BeforeAgentListener.class) +public class NocodeInitializer implements BeforeAgentListener { + + @Override + public void beforeAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) { + ConfigProperties config = getConfig(autoConfiguredOpenTelemetrySdk); + YamlParser yp = new YamlParser(config); + NocodeRules.setGlobalRules(yp.getInstrumentationRules()); + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentation.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentation.java new file mode 100644 index 000000000..990524259 --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentation.java @@ -0,0 +1,99 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import static com.splunk.opentelemetry.instrumentation.nocode.NocodeSingletons.instrumenter; +import static io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge.currentContext; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.none; + +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.instrumentation.api.incubator.semconv.util.ClassAndMethod; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import java.lang.reflect.Method; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +public final class NocodeInstrumentation implements TypeInstrumentation { + private final NocodeRules.Rule rule; + + public NocodeInstrumentation(NocodeRules.Rule rule) { + this.rule = rule; + } + + @Override + public ElementMatcher typeMatcher() { + // names have to match exactly for now to enable rule lookup + // at advice time. In the future, we could support + // more complex rules here if we dynamically generated advice classes for + // each rule. + return rule != null ? named(rule.className) : none(); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + rule != null ? named(rule.methodName) : none(), + this.getClass().getName() + "$NocodeAdvice"); + } + + @SuppressWarnings("unused") + public static class NocodeAdvice { + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter( + @Advice.Origin("#t") Class declaringClass, + @Advice.Origin("#m") String methodName, + @Advice.Local("otelInvocation") NocodeMethodInvocation otelInvocation, + @Advice.Local("otelContext") Context context, + @Advice.Local("otelScope") Scope scope, + @Advice.This Object thiz, + @Advice.AllArguments Object[] methodParams) { + NocodeRules.Rule rule = NocodeRules.find(declaringClass.getName(), methodName); + otelInvocation = + new NocodeMethodInvocation( + rule, ClassAndMethod.create(declaringClass, methodName), thiz, methodParams); + Context parentContext = currentContext(); + + if (!instrumenter().shouldStart(parentContext, otelInvocation)) { + return; + } + context = instrumenter().start(parentContext, otelInvocation); + scope = context.makeCurrent(); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void stopSpan( + @Advice.Origin Method method, + @Advice.Local("otelInvocation") NocodeMethodInvocation otelInvocation, + @Advice.Local("otelContext") Context context, + @Advice.Local("otelScope") Scope scope, + @Advice.Thrown Throwable error) { + if (scope == null) { + return; + } + scope.close(); + // This is heavily based on the "methods" instrumentation from upstream, but + // for now we're not supporting modifying return types/async result codes, etc. + // This could be expanded in the future. + instrumenter().end(context, otelInvocation, null, error); + } + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeMethodInvocation.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeMethodInvocation.java new file mode 100644 index 000000000..32be9aad1 --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeMethodInvocation.java @@ -0,0 +1,62 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.instrumentation.api.incubator.semconv.util.ClassAndMethod; +import java.util.Collections; +import java.util.Map; + +public final class NocodeMethodInvocation { + private final NocodeRules.Rule rule; + private final ClassAndMethod classAndMethod; + private final Object thiz; + private final Object[] parameters; + + public NocodeMethodInvocation( + NocodeRules.Rule rule, ClassAndMethod cm, Object thiz, Object[] parameters) { + this.rule = rule; + this.classAndMethod = cm; + this.thiz = thiz; + this.parameters = parameters; + } + + public NocodeRules.Rule getRule() { + return rule; + } + + public Object getThiz() { + return thiz; + } + + /** + * Please be careful with this, it's directly tied to @Advice.AllArguments. + * + * @return + */ + public Object[] getParameters() { + return parameters; + } + + public ClassAndMethod getClassAndMethod() { + return classAndMethod; + } + + public Map getRuleAttributes() { + return rule == null ? Collections.emptyMap() : rule.attributes; + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeModule.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeModule.java new file mode 100644 index 000000000..2f44e5a9f --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeModule.java @@ -0,0 +1,60 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import com.google.auto.service.AutoService; +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import java.util.ArrayList; +import java.util.List; + +@AutoService(InstrumentationModule.class) +public final class NocodeModule extends InstrumentationModule { + + public NocodeModule() { + super("nocode"); + } + + @Override + public List typeInstrumentations() { + List answer = new ArrayList<>(); + for (NocodeRules.Rule rule : NocodeRules.getGlobalRules()) { + answer.add(new NocodeInstrumentation(rule)); + } + // ensure that there is at least one instrumentation so that muzzle reference collection could + // work + if (answer.isEmpty()) { + answer.add(new NocodeInstrumentation(null)); + } + return answer; + } + + @Override + public boolean isHelperClass(String className) { + return className.startsWith("com.splunk.opentelemetry.instrumentation"); + } + + // If nocode instrumentation is added to something with existing auto-instrumentation, + // it would generally be better to run the nocode bits after the "regular" bits. + // E.g., if we want to add nocode to a servlet call, then we want to make sure that + // the otel-standard servlet instrumentation runs first to handle context propagation, etc. + @Override + public int order() { + return Integer.MAX_VALUE; + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSingletons.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSingletons.java new file mode 100644 index 000000000..c6ef04a1c --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSingletons.java @@ -0,0 +1,36 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; + +public class NocodeSingletons { + private static final Instrumenter INSTRUMENTER; + + static { + INSTRUMENTER = + Instrumenter.builder( + GlobalOpenTelemetry.get(), "com.splunk.nocode", new NocodeSpanNameExtractor()) + .addAttributesExtractor(new NocodeAttributesExtractor()) + .buildInstrumenter(new NocodeSpanKindExtractor()); + } + + public static Instrumenter instrumenter() { + return INSTRUMENTER; + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanKindExtractor.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanKindExtractor.java new file mode 100644 index 000000000..ee25915ba --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanKindExtractor.java @@ -0,0 +1,35 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor; +import java.util.Locale; + +public class NocodeSpanKindExtractor implements SpanKindExtractor { + @Override + public SpanKind extract(NocodeMethodInvocation mi) { + if (mi.getRule() == null || mi.getRule().spanKind == null) { + return SpanKind.INTERNAL; + } + try { + return SpanKind.valueOf(mi.getRule().spanKind.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException noMatchingValue) { + return SpanKind.INTERNAL; + } + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanNameExtractor.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanNameExtractor.java new file mode 100644 index 000000000..86db707ef --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeSpanNameExtractor.java @@ -0,0 +1,42 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.instrumentation.api.incubator.semconv.code.CodeSpanNameExtractor; +import io.opentelemetry.instrumentation.api.incubator.semconv.util.ClassAndMethod; +import io.opentelemetry.instrumentation.api.instrumenter.SpanNameExtractor; + +public class NocodeSpanNameExtractor implements SpanNameExtractor { + private final SpanNameExtractor defaultNamer; + + public NocodeSpanNameExtractor() { + this.defaultNamer = CodeSpanNameExtractor.create(ClassAndMethod.codeAttributesGetter()); + } + + @Override + public String extract(NocodeMethodInvocation mi) { + NocodeRules.Rule rule = mi.getRule(); + if (rule != null && rule.spanName != null) { + String name = JSPS.evaluate(rule.spanName, mi.getThiz(), mi.getParameters()); + if (name != null) { + return name; + } + } + return defaultNamer.extract(mi.getClassAndMethod()); + } +} diff --git a/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/YamlParser.java b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/YamlParser.java new file mode 100644 index 000000000..9f2ccd93d --- /dev/null +++ b/instrumentation/nocode/src/main/java/com/splunk/opentelemetry/instrumentation/nocode/YamlParser.java @@ -0,0 +1,90 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import com.splunk.opentelemetry.javaagent.bootstrap.nocode.NocodeRules; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +public final class YamlParser { + private static final Logger logger = Logger.getLogger(YamlParser.class.getName()); + // FIXME support method override selection - e.g., with classfile method signature or something + private static final String NOCODE_YMLFILE = "splunk.otel.instrumentation.nocode.yml.file"; + + private final List instrumentationRules; + + public YamlParser(ConfigProperties config) { + instrumentationRules = Collections.unmodifiableList(new ArrayList<>(load(config))); + } + + public List getInstrumentationRules() { + return instrumentationRules; + } + + private static List load(ConfigProperties config) { + String yamlFileName = config.getString(NOCODE_YMLFILE); + if (yamlFileName == null || yamlFileName.trim().isEmpty()) { + return Collections.emptyList(); + } + + try { + return loadUnsafe(yamlFileName); + } catch (Exception e) { + logger.log(Level.SEVERE, "Can't load configured nocode yaml.", e); + return Collections.emptyList(); + } + } + + private static List loadUnsafe(String yamlFileName) throws Exception { + List answer = new ArrayList<>(); + try (InputStream inputStream = Files.newInputStream(Paths.get(yamlFileName.trim()))) { + Load load = new Load(LoadSettings.builder().build()); + Iterable parsedYaml = load.loadAllFromInputStream(inputStream); + for (Object yamlBit : parsedYaml) { + List> rulesMap = (List>) yamlBit; + for (Map yamlRule : rulesMap) { + String className = yamlRule.get("class").toString(); + String methodName = yamlRule.get("method").toString(); + String spanName = + yamlRule.get("spanName") == null ? null : yamlRule.get("spanName").toString(); + String spanKind = + yamlRule.get("spanKind") == null ? null : yamlRule.get("spanKind").toString(); + List> attrs = (List>) yamlRule.get("attributes"); + Map ruleAttributes = new HashMap<>(); + for (Map attr : attrs) { + ruleAttributes.put(attr.get("key").toString(), attr.get("value").toString()); + } + answer.add( + new NocodeRules.Rule(className, methodName, spanName, spanKind, ruleAttributes)); + } + } + } + + return answer; + } +} diff --git a/instrumentation/nocode/src/test/config/nocode.yml b/instrumentation/nocode/src/test/config/nocode.yml new file mode 100644 index 000000000..91b664bd8 --- /dev/null +++ b/instrumentation/nocode/src/test/config/nocode.yml @@ -0,0 +1,24 @@ +- class: com.splunk.opentelemetry.instrumentation.nocode.NocodeInstrumentationTest$SampleClass + method: doSomething + spanName: "this.getName()" + attributes: + - key: "details" + value: this.getDetails() + - key: "map.size" + value: this.getMap().entrySet().size() + +- class: com.splunk.opentelemetry.instrumentation.nocode.NocodeInstrumentationTest$SampleClass + method: throwException + spanKind: SERVER + attributes: + - key: "five" + value: param0.toString().substring(0) + +- class: com.splunk.opentelemetry.instrumentation.nocode.NocodeInstrumentationTest$SampleClass + method: doInvalidRule + spanName: "this.thereIsNoSuchMethod()" + spanKind: INVALID + attributes: + - key: "notpresent" + value: "invalid.noSuchStatement()" + diff --git a/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/JSPSTest.java b/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/JSPSTest.java new file mode 100644 index 000000000..78f076f06 --- /dev/null +++ b/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/JSPSTest.java @@ -0,0 +1,208 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class JSPSTest { + private static final Map thiz = new HashMap<>(); + private static final Set param0 = new HashSet<>(); + + static { + thiz.put("key", "value"); + param0.add("present"); + } + + static Stream jspsToExpected() { + return Stream.of( + // Might be nice to support a bare "param0" or "this" but as a workaround can always use + // "this.toString()" + arguments("this.toString()", "{key=value}"), + arguments("this.toString().length()", "11"), + arguments("this.get(\"key\")", "value"), + arguments("this.get(\"key\").substring(1)", "alue"), + arguments("param0.isEmpty()", "false"), + arguments("param0.contains(\"present\")", "true"), + arguments("this.entrySet().size()", "1")); + } + + @ParameterizedTest + @MethodSource("jspsToExpected") + void testBasicBehavior(String jsps, String expected) { + assertEquals(expected, JSPS.evaluate(jsps, thiz, new Object[] {param0}), jsps); + } + + @ParameterizedTest + @ValueSource( + strings = { + "nosuchvar", + "nosuchvar.toString()", + "this .", + "this . ", + "this.noSuchMethod()", + "toString()", + "this.toString()extrastuffatend", + "this.toString()toString()", + "param1.toString()", // out of bounds + "param999.toString()", + "this.getOrDefault(\"key\", \"multiparamnotsupported\")", + "this.get(\"noclosequote)", + "this.get(\"nocloseparan\"", + "this.noparens", + "this.noparens.anotherMethod()", + "this.wrongOrder)(", + "this.get(NotALiteralParameter);", + "this.get(12.2)", + "this.get(this)", + "this.get(\"NoSuchKey\")", // evals completely but returns null + "param1.toString()", // no such param + }) + void testInvalidJspsReturnNull(String invalid) { + String answer = JSPS.evaluate(invalid, thiz, new Object[] {param0}); + assertNull(answer, "Expected null for \"" + invalid + "\" but was \"" + answer + "\""); + } + + @Test + void testIntegerLiteralLongerThanOneDigit() { + Map o = new HashMap<>(); + o.put("key", "really long value"); + String jsps = "this.get(\"key\").substring(10)"; + assertEquals("g value", JSPS.evaluate(jsps, o, new Object[0])); + } + + public static class TakeString { + public String take(String s) { + return s; + } + } + + public static class TakeObject { + public String take(Object o) { + return o.toString(); + } + } + + public static class TakeBooleanPrimitive { + public String take(boolean param) { + return Boolean.toString(param); + } + } + + public static class TakeBoolean { + public String take(Boolean param) { + return param.toString(); + } + } + + public static class TakeIntegerPrimitive { + public String take(int param) { + return Integer.toString(param); + } + } + + public static class TakeInteger { + public String take(Integer param) { + return param.toString(); + } + } + + public static class TakeLongPrimitize { + public String take(long param) { + return Long.toString(param); + } + } + + public static class TakeLong { + public String take(Long param) { + return param.toString(); + } + } + + @Test + void testBooleanLiteralParamTypes() { + TakeBooleanPrimitive b = new TakeBooleanPrimitive(); + TakeBoolean B = new TakeBoolean(); + TakeObject O = new TakeObject(); + assertEquals("true", JSPS.evaluate("this.take(true)", b, new Object[0])); + assertEquals("false", JSPS.evaluate("this.take(false)", B, new Object[0])); + assertEquals("true", JSPS.evaluate("this.take(true)", O, new Object[0])); + } + + @Test + void testStringLiteralParamTypes() { + TakeString S = new TakeString(); + TakeObject O = new TakeObject(); + assertEquals("a", JSPS.evaluate("this.take(\"a\")", S, new Object[0])); + assertEquals("a", JSPS.evaluate("this.take(\"a\")", O, new Object[0])); + } + + @Test + public void testIntegerLiteralParamTypes() { + TakeIntegerPrimitive i = new TakeIntegerPrimitive(); + TakeInteger I = new TakeInteger(); + TakeLongPrimitize l = new TakeLongPrimitize(); + TakeLong L = new TakeLong(); + TakeObject O = new TakeObject(); + assertEquals("13", JSPS.evaluate("this.take(13)", i, new Object[0])); + assertEquals("13", JSPS.evaluate("this.take(13)", I, new Object[0])); + assertEquals("13", JSPS.evaluate("this.take(13)", l, new Object[0])); + assertEquals("13", JSPS.evaluate("this.take(13)", L, new Object[0])); + assertEquals("13", JSPS.evaluate("this.take(13)", O, new Object[0])); + } + + @ParameterizedTest + @ValueSource( + strings = { + "this.get(\"key\").substring(1)", + " this.get(\"key\").substring(1)", + "this .get(\"key\").substring(1)", + "this. get(\"key\").substring(1)", + "this.get (\"key\").substring(1)", + "this.get( \"key\").substring(1)", + "this.get(\"key\" ).substring(1)", + "this.get(\"key\")\t.substring(1)", + "this.get(\"key\").\nsubstring(1)", + "this.get(\"key\").substring\r(1)", + "this.get(\"key\").substring( 1)", + "this.get(\"key\").substring(1 )", + }) + void testWhitespace(String test) { + assertEquals("alue", JSPS.evaluate(test, thiz, new Object[] {param0}), test); + } + + @Test + void testManyParams() { + Object[] params = new Object[13]; + Arrays.fill(params, new Object()); + assertEquals( + "java.lang.Object", JSPS.evaluate("param12.getClass().getName()", new Object(), params)); + } +} diff --git a/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentationTest.java b/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentationTest.java new file mode 100644 index 000000000..a938aca40 --- /dev/null +++ b/instrumentation/nocode/src/test/java/com/splunk/opentelemetry/instrumentation/nocode/NocodeInstrumentationTest.java @@ -0,0 +1,104 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.splunk.opentelemetry.instrumentation.nocode; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; +import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +// This test has "test/config/nocode.yml" applied to it by the gradle environment setting +class NocodeInstrumentationTest { + @RegisterExtension + static final InstrumentationExtension testing = AgentInstrumentationExtension.create(); + + @Test + void testBasicMethod() { + new SampleClass().doSomething(); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("name") + .hasKind(SpanKind.INTERNAL) + .hasAttributesSatisfying( + equalTo(AttributeKey.stringKey("map.size"), "2"), + equalTo(AttributeKey.stringKey("details"), "details")))); + } + + @Test + void testRuleWithManyInvalidFields() { + new SampleClass().doInvalidRule(); + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("SampleClass.doInvalidRule") + .hasKind(SpanKind.INTERNAL) + .hasTotalAttributeCount( + 2))); // two code. attribute but nothing from the invalid rule + } + + @Test + void testThrowException() { + try { + new SampleClass().throwException(5); + } catch (Exception expected) { + // nop + } + + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("SampleClass.throwException") + .hasKind(SpanKind.SERVER) + .hasEventsSatisfyingExactly(event -> event.hasName("exception")) + .hasAttributesSatisfying(equalTo(AttributeKey.stringKey("five"), "5")))); + } + + public static class SampleClass { + public String getName() { + return "name"; + } + + public String getDetails() { + return "details"; + } + + public Map getMap() { + HashMap answer = new HashMap<>(); + answer.put("key", "value"); + answer.put("key2", "value2"); + return answer; + } + + public void throwException(int parameter) { + throw new UnsupportedOperationException("oh no"); + } + + public void doSomething() {} + + public void doInvalidRule() {} + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 70b24d172..52a5428a7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ include( "instrumentation:jvm-metrics", "instrumentation:khttp", "instrumentation:liberty", + "instrumentation:nocode", "instrumentation:servlet-3-testing", "instrumentation:tomcat", "instrumentation:tomee",